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

Gonzalo is the persistence layer for the caliban-ai stack. It stores versioned records behind one generic Store trait, surfaces concurrent edits as conflicts rather than losing them, and lets the backing substrate be the local filesystem, a git repository, an S3-compatible object store, or a remote gonzalod daemon, as a matter of configuration.

On top of that core sit capability layers: typed domain views (memory tiers, topics, sessions, checkpoints), a normalized ticket layer, the fleet access-control records, vector search — in memory or durable — a knowledge store, and a tree-sitter code graph that agents query through an MCP server.

Where gonzalo fits

projectrole
calibanthe agent. Gonzalo started as a shareable home for its local-first state.
prosperoruns the caliban agent fleet
arielthe chat bridge for the fleet: Discord today, Slack and Teams later. Shipping at v0.3.0. Its people, identity bindings, role grants, channel configuration, link tokens and audit trail are gonzalo records, and ariel stores nothing of its own: it reads and writes them over gonzalod as the fleet access-control records that shipped in 0.7.0 (ADR 0022, ADR 0023). It is the first consumer of those kinds, and the reason they look the way they do.
gonzalopersistence: records, stores, capability layers, the daemon, and the code-graph MCP server

Rust consumers depend on the gonzalo facade crate. Everything else talks to the daemon over HTTP/JSON or gRPC (ADR 0020).

What you get

binary / cratewhat it isguide
gonzalo (crate gonzalo-cli)admin CLI: inspect and import records, sync stores, index code graphs, sync ticketsThe CLI
gonzalod (crate gonzalo-server)the daemon: a store served over HTTP/JSON and gRPC, with namespace-scoped authRunning gonzalod
gonzalo-mcpMCP server that answers code-graph and record queries for agentsThe MCP server
gonzalo (library)facade over the core, substrates and layers, selected by Cargo featureStorage backends

Where to go next

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). That surface is grouped by domain: the root holds the record and store core and nothing else, and every domain type lives under the module naming its layer — gonzalo::ticket::State, gonzalo::fleet::Person — so gonzalo's nouns cannot collide with a consumer's own, and two layers may use the same word (ADR 0026).
  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).
  13. Rust-native deliverables; the daemon is the non-Rust boundary. We ship Rust crates, the gonzalod image, and binaries of our own binary crates — not client SDKs in other languages. Non-Rust consumers integrate over the daemon and its published schema, which ADR 0007 already keeps from drifting (ADR 0020). The schema is published as artifacts, not just promised: the .proto and an OpenAPI document are attached to each release, so a client is generated and owned by its consumer rather than maintained here.
  14. Deletion is a write, not an absence. A delete writes a tombstone that sync and git pull replicate like any other record, so a delete made on one store takes effect on its peers instead of being undone by the next sync. Reclaiming the space is a separate, deliberate step (ADR 0021).
  15. Derived bytes are swept by marking from the records, never from a caller. Blobs are shared, so nothing frees one implicitly. GC builds its mark set from the store's own records — record bodies, the blob a tombstone pins, a graph manifest's slices, a vector manifest's shards, and the retained body of a manifest tombstone — and a caller cannot narrow it (ADR 0024, ADR 0029). The sweep also spares anything younger than a grace period, because a blob is uploaded before the record that references it commits (ADR 0028).
  16. Gonzalo stores; it does not decide. The fleet access-control records hold identity, role grants and channel configuration, and gonzalo never evaluates a role in them — the consumer reads them and decides. gonzalod keeps authorizing its own API by ADR 0015's token principal, which is unrelated to FleetRole (ADR 0022, ADR 0023).
  17. Declare what cannot be verified, and fail loudly on a mismatch. Gonzalo never runs the embedder behind a caller's vectors, so a durable vector index stores the embedding space and dimension it was opened with and errors naming both values when a later open disagrees. Configuration drift is caught once, at startup, instead of silently skewing every query after (ADR 0027).

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 result — PutResult::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/O — gonzalo-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). The store also stamps created and updated inside the same critical section that decides the write, overwriting whatever the client sent: times a caller can set are times a caller can lie about. A replication write keeps the source's times, because syncing a record is not an edit.
  15. ADRs are an append-only log — superseded, never deleted (ADR 0001).
  16. A consumer read never shows a tombstone — get and list hide them. Only the raw surface exposes a tombstone, and that is the surface replication uses; a deleted key can never reappear in a listing (ADR 0021, ADR 0025).
  17. A tombstone pins the blobs of the record it replaces until the tombstone itself is collected, so a delete never destroys content a peer can still sync back (ADR 0024). A record whose body is a blob pins that blob; a manifest, which names its blobs out of line, keeps its body in the tombstone so the pin survives with the mapping that makes the bytes nameable — which is also what lets undelete restore a deleted index (ADR 0029).
  18. The daemon's surface stays described, not merely implemented — the served-operation table is checked against the router's own source and against the published OpenAPI document, in both directions, so an operation can be neither served without a description nor described without being served (ADR 0007, ADR 0020).

The CLI

gonzalo is the admin and ops CLI. It works directly against a filesystem store on local disk: a directory, passed as --root, that holds records and code-graph views. It does not talk to a daemon.

Install it as described in The MCP server § Install. The CLI ships in the gonzalo-cli crate and in the macOS release archive.

gonzalo --help and gonzalo <command> --help are the authoritative reference. This page explains what each command is for.

The store root

Every command that reads or writes a store takes --root <dir>, defaulting to the current directory. A leading ~ is expanded to $HOME even when no shell is involved (a systemd unit, a container command:), so --root ~/.gonzalo behaves the same everywhere (#211).

Records live under <root>/<namespace>/<collection>/<id>.json. Code-graph views live alongside them. gonzalo-mcp reads the same root through GONZALO_ROOT.

Records

commanddoes
gonzalo status --root Rrecord counts grouped by namespace/collection
gonzalo list --root R [--namespace N] [--collection C]list record keys
gonzalo get --root R <namespace> <collection> <id>print one record as JSON
gonzalo migrate --root R --namespace N --collection C [--kind K] <src>recursively import files from <src> as records
gonzalo sync <root-a> <root-b>two-way sync of two filesystem stores
gonzalo delete --root R --namespace N --collection C --id I [--expected REV]write a tombstone for one record (replicates on sync); --expected is the revision object from gonzalo get's JSON output (e.g. {"counter":3,"hash":"…"})
gonzalo undelete --root R --namespace N --collection C --id Irestore a deleted manifest (graph or vector index) from its tombstone, before collect purges it; see Deleting an index, and getting it back
gonzalo purge --root R --namespace N --collection C --id Iphysically remove one tombstone, discarding its restore window; refuses a live record and an absent key (exit 1); see Discarding a tombstone
gonzalo reset --root R --namespace N [--collection C]tombstone every live record in a namespace or collection; idempotent, re-run to finish
gonzalo collect --root R --older-than 30d [--namespace N [--collection C]]physically purge tombstones at least that old; choose a horizon longer than any peer's longest gap between syncs

get exits non-zero with a message on stderr when the record is absent, and leaves stdout empty, so scripts can tell absent from present.

delete, undelete, reset, collect and sync accept --ancestor-cap <n> (default 32). Exit codes: 0 success, 1 error, 2 usage error, 3 conflict — delete with a stale --expected, reset leaving records that were edited concurrently (re-run it), or sync reporting conflicts or giving up before it converged (see below). collect reports conflicts but exits 0.

delete on a key that's already deleted (or never existed) exits 0 and ignores --expected: to a consumer the key is already absent, so there's nothing to check the expected revision against. This is the idempotent-delete rule, not a bug — see Deleting a record.

migrate --kind is one of topic (default), memory-tier, session or checkpoint. It prints how many files it imported and skipped.

sync copies records each side is missing, merges records both sides changed, and reports copied_to_a, copied_to_b, fast_forwarded, merged, conflicts and unconverged. It also replicates deletions, since tombstones are copied like records. Merging is keyed by record kind and uses stored ancestry for a 3-way merge (ADR 0016). A conflict it cannot resolve is reported, never silently overwritten.

unconverged counts keys whose write was still losing a race when sync gave up after its pass limit, which happens when writers never stop touching the same keys during a run. It is normally 0. When it isn't, the two stores may still disagree on those keys even though conflicts is 0: the run is not proof that they match. sync then prints a line on stderr and exits 3, so a script can re-run it once the writers settle rather than treating the run as a success.

Code graph

commanddoes
gonzalo index --root R --repo OWNER/NAME [--view V] <src>index a source tree into a view (--view defaults to main)
gonzalo gc --root R [--min-age 1h]sweep blobs no record references and old enough to be safe

index flags:

flageffect
--watchkeep running and re-index on filesystem changes until Ctrl-C
--debounce-ms Nwith --watch, quiet period after the last change (default 500)
--reconcile-secs Nwith --watch, seconds between full reconciles (default 300)
--gcafter indexing, run a whole-store GC (also applied on each reconcile under --watch), reporting gc.freed, gc.retained and gc.deferred
--include PATHindex a path a built-in rule would skip, such as vendored code. Repeatable. Cannot override .gitignore.
--require-parse-workerfail instead of falling back to in-process parsing when no gonzalo-parse-worker is found

Blobs are content-addressed and shared — across views, and between records with identical content — so deleting a view's source, or a record, does not free them. gc marks against every record in the store (record bodies, the blobs tombstones pin, every view's manifest slices, every shard blob a durable vector index's manifest names, and everything named by the retained body of a manifest tombstone) and reports scanned, freed, retained and deferred. A deleted record's blob is pinned by its tombstone until collect removes it; Deletion walks through the order.

Deleting a manifest is the one case where the tombstone keeps the record's body rather than discarding it: a manifest names its blobs out of line, so dropping the body would both unpin the shards and destroy the mapping that says which blob held which shard. Keeping it means gc still marks through a deleted index, and gonzalo undelete can restore one until collect purges the tombstone (ADR 0029).

deferred counts unreferenced blobs the sweep held back for being younger than --min-age (default 1h). A blob is uploaded before the record or manifest that references it commits, so a sweep that ran in that window used to delete a blob its writer was about to point at — and for a vector shard that loss is unrecoverable. The age rule closes most of that window (ADR 0028); --min-age takes one positive number and one unit (d, h, m, s), the same spellings as collect --older-than, and --min-age 0s is rejected. A nonzero deferred is normal and is the difference between "nothing to reclaim" and "not yet safe to reclaim".

The MCP server explains index output line by line, which languages are parsed, and how to keep a view fresh.

Tickets

Gonzalo can import a ticket board into the store as ticket records, with each card's column normalized into a state category (ADR 0010). Connections are configured in a TOML file; see tickets.example.toml in the repository.

export KANBAN_PROJECT_PAT=ghp_...        # token named by the connection's token_env
cp tickets.example.toml tickets.toml
gonzalo ticket sync --config tickets.toml --root ./store
gonzalo ticket list --root ./store
gonzalo ticket get  --root ./store --connection caliban-ai-board "caliban-ai/gonzalo#15"
gonzalo ticket move --config tickets.toml "caliban-ai/gonzalo#15" in_progress
commanddoes
ticket sync [--config F] [--root R] [--author A]sync every configured connection into the store. --config defaults to tickets.toml.
ticket list [--root R]list imported ticket keys
ticket get [--root R] [--connection C] <uid>show one ticket. Board records are keyed per connection, so pass --connection for them (#159).
ticket move [--config F] [--connection C] <uid> <category>move the card to the column for a category: triage, backlog, open, in_progress, pending, done or canceled

The config file currently accepts one provider, github-projects (GitHub Projects v2). The Jira, Linear, GitLab, Asana and GitHub-issues connectors are available as library crates behind the facade's ticket-* features, but are not wired into tickets.toml. The daemon exposes the same sync as POST /v1/tickets/sync; see Running gonzalod.

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

Three binaries are involved and they must all be at the same version: gonzalo, gonzalo-mcp, and gonzalo-parse-worker. The last one isolates tree-sitter parsing in a subprocess so one bad file skips instead of aborting the whole index. Indexing works without it, falling back to in-process parsing — so it is not optional in practice, just easy to forget. gonzalo index now says which mode it is in, and --require-parse-worker turns the fallback into a hard error for CI and container builds that should not take isolation on trust.

Apple Silicon: the release archive

Releases carry one prebuilt archive holding all three, which is the shortest path to a set that cannot be mismatched. Take the tag from the latest release — or let gh read it for you:

tag=$(gh release view --repo caliban-ai/gonzalo --json tagName -q .tagName)
base="https://github.com/caliban-ai/gonzalo/releases/download/$tag"
pkg="gonzalo-$tag-aarch64-apple-darwin"
curl -fsSLO "$base/$pkg.tar.gz" -O "$base/$pkg.tar.gz.sha256"
shasum -a 256 -c "$pkg.tar.gz.sha256"
tar xzf "$pkg.tar.gz"
install -m 755 "$pkg"/gonzalo "$pkg"/gonzalo-mcp "$pkg"/gonzalo-parse-worker ~/.cargo/bin/

Archives start with the first release after v0.5.0; earlier releases carry no assets, so build from the registry below if you need one of those.

The binaries are ad-hoc signed, not notarized. A curl download runs as-is; a browser download is quarantined and needs xattr -d com.apple.quarantine "$pkg"/*.

macOS arm64 is the only prebuilt target. Everywhere else, build from the registry.

Anywhere else: from crates.io

v=0.7.0   # the version you want; `cargo search gonzalo-cli` shows the newest
cargo install "gonzalo-cli@$v" "gonzalo-mcp@$v" "gonzalo-parse@$v"

Pin the version on all three. They are separate crates, so nothing stops them landing at different versions — and a gonzalo newer than its worker will index a view with the old parser and then mark that view current, which no later re-index corrects. An unpinned install can also silently serve a stale version for up to an hour after a release, while the crates.io sparse index catches up.

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

Files are parsed by extension. Supported languages:

languageextensions
Rust.rs
Python.py
JavaScript.js .jsx .mjs .cjs
TypeScript.ts .mts .cts .tsx
Go.go
Java.java
Kotlin.kt .kts
C#.cs
C.c
C++.h .cpp .cc .cxx .hpp .hh
Ruby.rb
PHP.php
Bash.sh .bash
Swift.swift
Lua.lua
Scala.scala .sc
Elixir.ex .exs

How much each language records varies: imports, qualifiers and receiver types are recorded for some languages and not others. Capability boundaries says which.

Output tells you what happened:

parse:    isolated worker /Users/you/.cargo/bin/gonzalo-parse-worker (beside the executable)
driver:   full walk
files:    465
added:    465
modified: 0
deleted:  0
skipped:  0
ignored:  2 files, 9 dirs not descended
  • parse — isolated worker <path> when tree-sitter runs in a crash-isolated subprocess, or in-process (no worker found) when it does not. The two produce identical graphs, so this line is the only way to tell whether a grammar that abort()s will skip one file or kill the run. In-process also prints a warning naming every location searched — the GONZALO_PARSE_WORKER override, beside the executable, then each PATH entry. Pass --require-parse-worker to fail instead.
  • driver — full 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. The format recorded against a view is the one the parse worker reported, not the one gonzalo was built with — so a worker left behind at an older version rebuilds the view and credits it honestly, and upgrading the worker later rebuilds it again. A mismatch prints a warning naming both versions; install a matching gonzalo-parse-worker to clear it.
  • 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

A leading ~ in GONZALO_ROOT (and in --root) is expanded to $HOME, so GONZALO_ROOT=~/.gonzalo works even though no shell is involved — neither bash nor zsh expands a tilde in --env KEY=~/path argument position, and an MCP client hands the value to the process verbatim. Before this was fixed it created a directory literally named ~ in the current working directory and indexed nothing you could find (#211). status reports the expanded path, so check it there if in doubt. Only a leading ~ is expanded, and ~otheruser/... is left alone.

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.

Writes are opt-in

record_get and record_list are always available. record_put and record_delete are exposed only when the server is started with --allow-writes (or GONZALO_MCP_ALLOW_WRITES=1), and when it is not, they are absent from the advertised tool list — an agent never sees a tool it would not be allowed to call. Mutating a store is the operator's decision, not the agent's:

claude mcp add gonzalo --env GONZALO_ROOT=~/.gonzalo -- gonzalo-mcp --allow-writes

Against a running daemon

Point the server at a gonzalod instead of a local directory, and records come from that shared store (ADR 0007):

claude mcp add gonzalo \
  --env GONZALO_DAEMON=http://gonzalo.internal:8080 \
  --env GONZALO_TOKEN=… \
  -- gonzalo-mcp

--daemon <url> does the same for a client that passes arguments rather than environment. GONZALO_TOKEN is sent as a bearer token when the daemon requires one; what the token is scoped to decides what the tools can reach, so a read-only token and a read-only server are two independent locks.

In daemon mode there is no local graph root: views are assembled from content-addressed slices fetched over the daemon, so the code-graph tools work against whatever that store holds. status reports the daemon URL as its root.

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

Records — the store itself, not the code graph. No view selector; these address a record by namespace, collection and id.

toolanswers
record_getread one record: body, kind, revision, provenance
record_listthe keys in a namespace, or in one collection of it
record_putwrite a record — only when the server allows writes
record_deletedelete a record — only when the server allows writes

record_get on a key that holds nothing is an error naming the key, not an empty result, for the same reason an unknown view is: an agent reads nothing as "there is nothing there" rather than "you asked the wrong question". Deleted records are hidden from both record_get and record_list. record_delete is the exception — deleting an absent key succeeds and changes nothing, because to a consumer that key is already gone.

A write that loses a race is not an error either: record_put answers {"committed": false, …} carrying the record the store actually holds, and record_delete answers {"deleted": false, …} the same way (ADR 0005). Re-read and retry; never retry blind.

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.

top, list and unreferenced are bounded and report total and truncated beside the items, so a capped list never masquerades as a complete one. overview's counts are never truncated — only its largest_files is capped, by largest. impact reports truncated when it stopped at max_depth with a frontier still unexplored.

Parameters

Every tool except status and views selects a view with repo and view_id (diff takes view_a and view_b instead), and all of those are required.

toolother parameters
search, node, callers, callees, explorename (required)
impactname (required); max_depth, where 0 or omitted means unbounded
diffview_a, view_b (both required)
overviewlargest: how many of the largest files to list (default 20)
topby (required): fan_in, fan_out or definitions; limit (default 20)
listpath_prefix, kind, name_contains, limit (default 100)
unreferencedpath_prefix, kind, name_contains, exclude_tests (default true), limit (default 100)

The record tools take no view selector:

toolparameters
record_get, record_deletenamespace, collection, id (all required)
record_listnamespace (required); collection to narrow it
record_putnamespace, collection, id, body (all required); kind (default Topic); expected_revision

list and unreferenced take a symbol kind: one of function, struct, enum, trait, impl, module, const, static, type_alias, class or interface.

record_put's kind is a record kind instead — MemoryTier, Topic, Session, Checkpoint, Ticket, TicketEvent, GraphManifest, or one of the fleet kinds (Person, IdentityBinding, RoleGrant, ChannelConfig, LinkToken, AuditEntry) — and defaults to Topic. An unknown kind is an error rather than silently the default, and a tombstone cannot be written directly: deleting is record_delete's job. Omitting expected_revision means "this key must not exist yet", so a record_put cannot silently overwrite.

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.

callers and callees now say how ambiguous the name is. Each answers with a definition_count beside the list, plus defined_in while there are few enough paths to be useful (#249). A count above 1 means the list merges several same-named symbols and is not the question you asked; a count of 0 means the name is defined nowhere in the view, which is what makes an empty list readable. The list itself moved under a callers/callees key, so these two tools return an object rather than a bare array.

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.

Imports narrow an otherwise ambiguous name. When several files define a name and the calling file has none of them, gonzalo consults that file's imports: use crate::model::make prefers the make under model.rs (#252). This is lexical, not a resolved module path — an import says which module a name came from, and that module name almost always appears in its own file's path. If the import fits several candidates the reference stays ambiguous, so this only ever narrows. Recorded for Rust, Python, Java, Kotlin, JavaScript, TypeScript, and C/C++ (where a #include names a whole file rather than one symbol, and is matched by path); a wildcard or glob import contributes nothing, since it introduces names the file never spells out. An import naming something outside the view — a JDK or npm type — matches nothing and the reference stays ambiguous, which is the honest answer.

An import is read relative to the file that wrote it. A Python relative import (from ..DataTypes import Action) anchors at the referencing file's own package, and an absolute import matching several identical copies of a package tree resolves to the copy nearest the referencing file (#261). This is what makes a repo holding a vendored reference tree beside your own work, or one package tree per assignment, resolvable at all. A genuine tie is still left ambiguous.

A method call is attributed when the body says what the receiver is. b.get() resolves to Beta's get when b came from Beta::new(), a Beta { .. } literal, or a parameter written b: Beta / &Beta, and self.foo() takes the enclosing impl (#251). This is syntax, not inference: a receiver from a plain call (let b = make()), a generic, or a trait object still declines and is still counted in receiver_unknown_edges. Rust only for now; other languages record no receiver type. On gonzalo's own source impact on insert went from 60 symbols reached to 219.

A call that names its type is now attributed to it. Beta::get() resolves to Beta's get even when several types in the view define one, because the call site's qualifier is recorded alongside each definition's owning type (#248). Two limits are worth knowing. The qualifier only ever narrows: when it names something the view does not own — a file module, a dependency — it is ignored and the older rules apply, so this never turns an answer into a decline. And it is recorded only where the grammar makes a path unambiguously a path, currently Rust, C++, and PHP. Go's pkg.Func() and Java's Foo.bar() are indistinguishable from a receiver call at this level, so they stay unqualified rather than record a variable name as a type. A definition's owner shows up in search and node results when it has one.

A call can be attributed to a module-level declaration, not just a function. A great deal of modern TypeScript lives in module-level builder objects — a Zod schema, a tRPC router — and calls inside them used to reach neither callers nor impact (#268). They are now attributed to the nearest named binding, and reported separately: callers still means functions that call this, while module_callers carries the module-level ones. The nearest name wins, so z.string() under a PORT: key reports PORT rather than the whole schema. Deliberately over-inclusive in the same way value references are — a module-level const rows = items.map(..) attributes its lambda's calls to rows — which is why the two are separate fields rather than one list.

In JavaScript and TypeScript, an anonymous callback has no caller. A call inside it('...', () => { ... }) records no enclosing function, because that arrow has no name. Test files therefore contribute little to callers and impact, though they still count for unreferenced. Functions bound to a name do resolve, including through a higher-order wrapper such as React.memo (#257).

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 now recorded and no longer reported as dead (#250). An unused name is still hidden by any same-named symbol that is used. Confirm every hit against the source.

Passing a function is not calling it. A name handed to a call is recorded as a value reference: it keeps unreferenced honest, and it is deliberately kept out of callers, callees and top, so those stay call-graph answers. impact counts them in value_edges rather than walking them, because extraction is per-file and cannot tell an identifier naming a function from one naming a local — traversing them would put false edges back into the walk. A non-zero value_edges means something depends on the seed through a callback and is worth a look.

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

Running gonzalod

gonzalod serves one store over two transports at once: HTTP/JSON (axum) and gRPC (tonic), both generated from one schema in gonzalo-proto (ADR 0007). It is the integration point for anything that is not a Rust program linking the gonzalo crate (ADR 0020).

Run it

From a checkout:

cargo run --release -p gonzalo-server --bin gonzalod

Or the container image, which binds all interfaces and keeps data in a /data volume:

docker run --rm -p 8080:8080 -p 50051:50051 \
  -v gonzalo-data:/data ghcr.io/caliban-ai/gonzalo

On startup it prints one line naming what it chose:

gonzalod: store fs(./gonzalo-data), ancestor cap 32, HTTP on 127.0.0.1:8080, gRPC on 127.0.0.1:50051, auth off

Configuration

All configuration is environment variables. A malformed value is a startup error, not a silent fallback.

variablepurposedefault
GONZALO_STOREsubstrate: fs or s3fs
GONZALO_ROOTfs store root./gonzalo-data (image: /data)
GONZALO_S3_BUCKETbucket, required when GONZALO_STORE=s3none
GONZALO_S3_ENDPOINTS3 endpoint for a self-hosted store such as RustFSambient AWS config
GONZALO_S3_REGIONregion overrideambient AWS config
AWS_*S3 credentials, read the standard waynone
GONZALO_ANCESTOR_CAPrevisions kept in each record's ancestor list (see Ancestor cap)32
GONZALO_HTTP_ADDRHTTP bind address127.0.0.1:8080 (image: 0.0.0.0:8080)
GONZALO_GRPC_ADDRgRPC bind address127.0.0.1:50051 (image: 0.0.0.0:50051)
GONZALO_MAX_BLOB_SIZEmax bytes per blob over the transports64 MiB
GONZALO_AUTH_FILETOML principals file for namespace-scoped authunset
GONZALO_TOKENsingle admin token, used only when no auth file is setunset

GONZALO_MAX_BLOB_SIZE also raises the gRPC decode limit for every RPC, because tonic's limit is per server rather than per method. HTTP scopes it to the blob routes only (#194).

With fs, code-graph views written by gonzalo index under <root>/graphs are queryable through the daemon. With s3 there is no local graph cache; views assemble from their manifest and content-addressed slices on demand.

Git is not a daemon substrate. See Storage backends for choosing between fs and s3, and for which S3 implementations are safe to run several replicas against.

Auth

Auth is resolved in this order (ADR 0015):

  1. GONZALO_AUTH_FILE set: parse it as a principals file.
  2. Otherwise GONZALO_TOKEN set: one admin principal named root with that token.
  3. Otherwise auth is off. Every request is served as an implicit admin.

A principals file maps bearer tokens to named principals with per-namespace scopes. "*" means every namespace:

[[principal]]
name  = "caliban"
token = "s3cret"
read  = ["memory", "sessions"]
write = ["memory"]

[[principal]]
name  = "admin"
token = "root"
read  = ["*"]
write = ["*"]

Duplicate tokens are rejected at startup. Clients send Authorization: Bearer <token> as an HTTP header or as gRPC authorization metadata. A missing or unknown token is 401. A known token without the needed scope is 403.

What each operation needs:

operationneeds
get a recordread on its namespace
put or delete a recordwrite on its namespace
list keys in a namespaceread on that namespace
list keys with no namespace filterread on "*"
raw get a recordread on its namespace
raw put a recordadmin ("*" in both lists)
raw key listingas list keys
purge a recordadmin ("*" in both lists), and only a tombstone
code-graph queriesread on the view's repo
blobsread / write on the reserved _blobs namespace
ticket syncwrite on tickets
/healthz, /readyznothing: probes bypass auth

When auth is on, a write's meta.author is overwritten with the authenticated principal's name, so authorship cannot be forged — except put_raw and delete from an admin, which keep the replicated author or the named deleter. put_raw is admin-only, so a replicated record always keeps its original author. With auth off, the author the client sent is kept. See Deletion, reset & collection § Over the daemon for the full authorship rules.

HTTP API

method and pathdoes
GET /healthzliveness: always 200 ok
GET /readyzreadiness: 200 when the backing store is reachable, else 503
GET /v1/records/{ns}/{col}/{id}the record as JSON, or 404
PUT /v1/records/{ns}/{col}/{id}body {"record": …, "expected": <revision or null>}
DELETE /v1/records/{ns}/{col}/{id}optional body {"expected": <revision>, "author": <identity>}, both fields optional
GET /v1/keys?namespace=&collection=list keys, both filters optional
GET /v1/raw/records/{ns}/{col}/{id}the record as JSON including tombstones; always 200, absence is {"record": null}
PUT /v1/raw/records/{ns}/{col}/{id}replication write, admin only; body {"record": …, "expected": <revision or null>}, stored verbatim
GET /v1/raw/keys?namespace=&collection=list keys including tombstoned ones, both filters optional
POST /v1/purge/{ns}/{col}/{id}body {"expected": <revision>}; physically removes the tombstone. 400 if the key is live at that revision
GET, PUT, DELETE /v1/blobs/{hash}content-addressed blob bytes
HEAD /v1/blobs/{hash}whether the blob exists: 200 or 404, no body. Answered from a presence check, so content-length is 0 rather than the blob's size
GET /v1/blobslist blobs, as objects carrying each blob's hash and modified time
POST /v1/tickets/syncsync one ticket connection; the body is one [[connection]] entry from tickets.toml, as JSON
GET /v1/graph/{definitions,references,callers,callees,impact}?repo=&view=&name=code-graph queries against an indexed view

A put whose URL disagrees with the record's key is 400. A put or delete whose expected revision is stale returns 409 with {"outcome":"conflict","conflict":…} carrying the live record. A successful put returns {"outcome":"committed","revision":…}. Conflicts are normal results, not errors (ADR 0005).

A put rejected as not found — a conditional write (normal or raw) whose expected revision the store no longer holds, the common case being a write over a deleted key — returns 412. Other internal failures return an opaque 500; the detail goes to the daemon's stderr.

This surface is described by docs/api/openapi.json, which the test suite checks against the router in both directions, and which each release attaches to its GitHub Release. Generate a client from it rather than hand-writing one — see Generating a client.

gRPC API

The gonzalo service in gonzalo-proto carries the same operations: Get, Put, Delete, List, GetRaw, ListRaw, PutRaw, Purge, PutBlob, GetBlob, HasBlob, ListBlobs, DeleteBlob, TicketSync, and GraphDefinitions, GraphReferencesTo, GraphCallersOf, GraphCallees, GraphImpact. Authorization is identical to HTTP, and the .proto is published per release alongside the OpenAPI document (Generating a client).

From Rust

A Rust program can use a daemon as an ordinary Store. Enable the facade's remote feature and construct a ServerStore with ServerStore::http(url) or ServerStore::grpc(endpoint), or their *_with_token variants. Any code written against Store then runs unchanged against the daemon.

Container notes

The image is ghcr.io/caliban-ai/gonzalo, built from the repository's Dockerfile. It runs as uid 10001 and exposes 8080 and 50051. Point liveness at /healthz and readiness at /readyz; neither needs a token. See also docs/container.md in the repository.

Generating a client

Gonzalo ships Rust deliverables only (ADR 0020). There is no Python package, no npm package, and there will not be one — a maintained SDK per language is a promise this project will not keep well.

What it ships instead is the description of the surface, so you can generate the client you need and own it yourself. That is what ADR 0007 means when it says the daemon exists so non-Rust tools can share a store.

The artifacts

Both descriptions are attached to each GitHub Release:

filedescribesfor
gonzalo-vX.Y.Z.protothe gRPC surfaceprotoc, buf, and every gRPC toolchain
gonzalo-openapi-vX.Y.Z.jsonthe HTTP/JSON surfaceOpenAPI generators, and anything that reads OpenAPI 3.1

Attached releases start with the first one after v0.7.0 — the workflow that uploads them landed after that tag, so v0.7.0 and earlier carry only the macOS archive.

Both also live in the repository — crates/gonzalo-proto/proto/gonzalo.proto and docs/api/openapi.json — and the .proto ships inside the gonzalo-proto crate on crates.io. For a release with no attached artifact, read them from the repository at that tag, which is the same content:

base=https://raw.githubusercontent.com/caliban-ai/gonzalo/v0.7.0
curl -fsSL "$base/docs/api/openapi.json" -o gonzalo-openapi-v0.7.0.json
curl -fsSL "$base/crates/gonzalo-proto/proto/gonzalo.proto" -o gonzalo-v0.7.0.proto

They cannot drift from what the daemon serves. The route table, the router and the OpenAPI document are checked against each other by the test suite, in both directions: an operation added to the daemon without describing it fails CI, and so does one described but not served. A description nothing checks is worse than none, because it is believed.

Otherwise take them from the release:

tag=$(gh release view --repo caliban-ai/gonzalo --json tagName -q .tagName)
gh release download "$tag" --repo caliban-ai/gonzalo \
  --pattern 'gonzalo-openapi-*.json' --pattern 'gonzalo-*.proto'

A worked example: Python over HTTP/JSON

Generate a client with any OpenAPI generator. With openapi-python-client:

pipx run openapi-python-client generate \
  --path gonzalo-openapi-v0.7.0.json \
  --meta none --output-path gonzalo_client

Then read and write records against a running daemon:

import httpx

BASE = "http://localhost:8080"
TOKEN = "…"                      # omit when the daemon serves open
auth = {"Authorization": f"Bearer {TOKEN}"}

# Create: `expected` omitted means "this key must not exist yet", so this
# cannot silently overwrite someone else's record.
record = {
    "key": {"namespace": "memory", "collection": "topics", "id": "rust"},
    "kind": "Topic",
    "revision": {"counter": 0, "hash": ""},   # the store stamps the real one
    "body": {"Inline": list(b'{"slug":"rust","bullets":[]}')},
    "meta": {
        "author": {"id": "ada"},
        "origin_system": "example",
        "created": 0,
        "updated": 0,
        "labels": {},
    },
    "links": [],
}
put = httpx.put(
    f"{BASE}/v1/records/memory/topics/rust",
    json={"record": record, "expected": None},
    headers=auth,
).json()

if put["outcome"] == "conflict":
    # Not an error: someone got there first, and their record came back with it.
    current = put["conflict"]["current"]
    print("already at", current["revision"])
else:
    print("committed", put["revision"])

got = httpx.get(f"{BASE}/v1/records/memory/topics/rust", headers=auth).json()
print(bytes(got["body"]["Inline"]))

Three things about that exchange are worth keeping when you write your own client, because they are the semantics rather than the syntax:

  • A conflict is data, not an error. PUT answers 200 with {"outcome": "conflict", …} carrying the record the store actually holds (ADR 0005). Re-read, re-apply, retry — never retry blind.
  • expected is the whole concurrency story. Omit it to create; pass the revision you read to update. A client that always omits it will find it can only ever create.
  • A delete is not a local erasure. It writes a tombstone that replicates (ADR 0021), so a deleted key stays deleted when peers sync, and GET /v1/records/... returns 404 while GET /v1/raw/records/... still shows the tombstone.

Over gRPC

Same story with gonzalo.proto and your language's gRPC plugin:

python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. gonzalo-v0.7.0.proto

The two transports carry the same types over one canonical schema, so which one you generate from is a deployment choice rather than a semantic one.

What not to do

Don't hand-write a client against these docs and keep it in sync by hand. Don't vendor a copy of the schema that you forget to update. Regenerate from the release artifact when you upgrade the daemon — that is the entire reason it is published per release.

Storage backends

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

The facade

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

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

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

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

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

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

Choosing a substrate

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

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

S3: only atomic conditional writes are safe

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

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

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

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

S3: what a listing costs

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

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

Concurrency and conflicts

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

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

Deletion

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

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

Blobs

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

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

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

Vector search and embeddings

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

A durable vector index

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

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

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

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

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

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

Deleting records, resetting namespaces, and collecting tombstones

Deleting a record in gonzalo is a replicated write. When you delete a record, the store keeps a small marker, a tombstone, where the record was. The tombstone travels to other stores through sync and git pull like any other change, so a delete made on one machine takes effect everywhere and stays in effect.

Up to 0.6, deletion only removed the record from the store you ran it on, and the next sync with a peer that still had the record brought it back. That no longer happens. The design is recorded in ADR 0021.

Upgrade every binary together

Warning. 0.7 adds a record kind that older binaries cannot read. Upgrade every gonzalo binary that touches a store, and every program built on gonzalo-core, at the same time.

  • A 0.6 binary that reads a store holding a tombstone fails on that key with a serialization error. This is loud, and no data is lost.
  • A 0.6 binary that runs sync against 0.7 data can't see tombstones and copies deleted records back. This is silent, and nothing on the new side can detect or block it.
  • A 0.7 client talking to a 0.6 gonzalod still gets, puts, lists and deletes normally. Replication reads fail with daemon predates replication reads (gonzalo#203); upgrade gonzalod instead of quietly doing the wrong thing.

Two kinds of reads

Everything an application does (gonzalo list, gonzalo get, the MCP server, memory tiers, tickets) uses consumer reads. Consumer reads hide tombstones, so a deleted record looks exactly like one that never existed.

Replication uses raw reads, which show tombstones, and raw writes, which store a record exactly as given. sync, git pull and gonzalo collect need to see deletes in order to propagate or clean them up. You won't normally use either yourself. On the daemon they are separate routes (see Over the daemon).

The gonzalo delete, reset and collect commands below work on a local store directory given by --root, which defaults to the current directory. For a store behind gonzalod, call gonzalo_core::reset_as or collect with a ServerStore: each tombstone goes through the daemon's delete route and its authorization.

Exit codes

CodeMeaning
0Success. For collect, conflicts are reported but aren't failures
1Error: I/O, the store, or --ancestor-cap 0 (ancestor cap must be at least 1)
2Usage error: a missing or malformed argument
3Conflict (delete and reset only): someone changed a record you were deleting. Re-read, or re-run the reset

Every command takes --root <DIR> (default ., a leading ~ is expanded) and --ancestor-cap <K> (see Ancestor cap). Each command's --help ends with its exit codes.

Deleting a record

gonzalo delete --root ~/.gonzalo --namespace notes --collection topics --id rust-async
deleted: notes/topics/rust-async

The command exits 0 when the record is deleted, and also when it was already deleted or never existed: deleting is idempotent.

Pass --expected to delete only if nobody has changed the record since you read it. The value is the revision object from gonzalo get's JSON output (e.g. {"counter":3,"hash":"…"}). Anything that isn't valid revision JSON is a usage error (exit 2):

gonzalo delete --root ~/.gonzalo --namespace notes --collection topics --id rust-async \
  --expected '{"counter":3,"hash":"9f2c…"}'

If someone has changed the record, nothing is deleted, and the command prints the current revision and exits 3:

conflict: notes/topics/rust-async
current:  {"counter":4,"hash":"b71e…"}

A tombstone names who deleted the record as its author. gonzalo delete and gonzalo reset both record gonzalo-cli. Library reset and Store::delete keep each record's author unless reset_as or delete_as names a deleter. Through the daemon, a non-admin token is always recorded as itself. An admin token, or a daemon without auth, keeps a named deleter; with no name, an admin is recorded as itself and a daemon without auth keeps the record's last author.

Deleting a key this store has never seen writes nothing. If a peer holds a record you want gone, delete it on that peer, or sync first and then delete.

On a filesystem store, deleting a key takes a per-record lock file (<id>.json.lock) and creates the namespace and collection directories if they don't exist, and these are left in place: removing a lock file another process holds would break the lock. So gonzalo delete with a mistyped --root or namespace creates those directories and still exits 0. Check the --root you pass. Reads and listings ignore lock files.

Delete versus a concurrent edit

If you delete a record on one machine while someone edits the same version on another, sync can't know which intent should win. It reports a conflict for that key and leaves both sides as they are, the same way it reports any edit it can't merge. Resolve it by deleting again or rewriting the record on one side, then sync.

Recreating a deleted record

Writing a record to a deleted key simply recreates it, and the new record wins over the old delete everywhere it syncs. Gonzalo sets the new record's revision so that it comes after the delete. Programs writing through the API should use the revision the write returns, not the one they built themselves. A conditional write that names a revision to a deleted key fails with "not found", because to an application the key doesn't exist. Write it unconditionally to recreate it. The one exception is a deleted graph or vector manifest, where the create is refused; see Deleting an index, and getting it back.

Resetting a namespace

reset deletes every live record in a namespace, or in one collection of it:

gonzalo reset --root ~/.gonzalo --namespace scratch
gonzalo reset --root ~/.gonzalo --namespace scratch --collection sessions

It prints a summary such as 12 deleted, 0 conflicts on stdout and exits 0. If any record was edited during the reset, it also prints one conflict: <ns/col/id> line per record to stderr, followed by a final re-run `gonzalo reset` to delete the N conflicted record(s) line on stderr, and exits 3. Run it again to finish. --namespace is required, and leaving it out is a usage error (exit 2). Resetting every namespace at once isn't offered; loop over namespaces if that's really what you want.

Reset is not atomic. It deletes records one at a time, and a record someone edits during the reset is reported as a conflict and left alone. Reset is safe to re-run: a second run deletes what the first missed and skips what's already gone. Because reset is made of ordinary deletes, it replicates like them. Over a daemon it needs both read and write on the namespace: reset_as lists and reads each record (read) before deleting it (write).

On a git store every tombstone is its own commit, so resetting N records makes N commits (and collecting N tombstones makes N more). reset and collect stop at the first store error (for example, a daemon token without the needed scope) and report it; what they already did stays done, so re-running after fixing the cause is safe.

Collecting tombstones

Tombstones are small, but they stay until you remove them. collect physically removes tombstones older than a horizon you choose:

gonzalo collect --root ~/.gonzalo --older-than 90d
gonzalo collect --root ~/.gonzalo --older-than 90d --namespace scratch

--older-than is required and has no default. It takes a positive integer and exactly one unit, d, h, m or s (90d, 36h). Zero, a missing unit, a compound value such as 1d12h, an unknown unit, or a value too large are all usage errors (exit 2). The command prints the horizon as you typed it, with its length in seconds, then how many tombstones it purged, how many it kept because they carry no deletion time, and how many conflicted:

horizon:   90d (7776000s)
purged:    41
unstamped: 0
conflicts: 0

A conflict means the key was recreated while collection ran, and the new record is left intact. Each conflict is also printed as a conflict: <key> line on stderr. collect still exits 0 with conflicts, since nothing needs retrying. Without --namespace, it runs across the whole store. --collection without --namespace is a usage error (exit 2).

There is no default horizon, and nothing collects automatically. That is deliberate, and the next section explains why.

Collection only frees space in the store you run it on. If a peer still holds the tombstone, the next sync copies it back. That's harmless (it's still a delete), but to reclaim space everywhere, run collect on every store with the same horizon.

On an S3 store, collecting also makes listings cheaper. A tombstone there carries a marker object that list reads to resolve it, so the reads a listing does are bounded by the tombstones outstanding (ADR 0025). Collecting removes both, and a collection with no tombstones left is listed without reading a single record.

Choosing a collection horizon

This is the one decision in this page that can lose data.

A tombstone is the only thing that stops a deleted record coming back. Suppose you collect a tombstone, and later a peer that was offline the whole time syncs. That peer still holds the live record and never saw the delete. With the tombstone gone, sync treats the peer's copy as a record this store has never seen and copies it back. The record is resurrected, on a different machine, possibly weeks later, with no error anywhere.

So the rule is:

The horizon must be longer than the longest time any peer might go without syncing, plus a safety margin.

Gonzalo can't know how long your peers stay offline, which is why you have to say.

Your setupLongest realistic gap between syncsSuggested --older-than
One store, never synced with anythingnoneany, e.g. 7d
A few machines that sync with a daemon dailya long weekend, a holiday30d–60d
Laptops that can be offline for weeks (travel, leave)several weeks90d or more
Peers you don't control, or you don't knowunknowndon't collect, or 180d+

Practical advice:

  • When in doubt, go longer. A horizon that's too long costs a little storage. One that's too short costs data, silently, later.
  • Clocks. The horizon is measured against each tombstone's deletion time. A machine whose clock was ahead produces tombstones that look younger than they are, so they're collected later, never earlier. Clock skew can delay collection but can't trigger it early.
  • Log the output. If you run collect from a scheduler, keep its output. It records the horizon used, which is what you'll want to know if a record ever reappears.
  • Retiring a peer? Sync it one last time before you drop it, so its view of deletes is current. A peer that is gone for good can't resurrect anything.

Reclaiming a deleted record's bytes

Deleting a blob-backed record doesn't free the blob, and neither does collecting its tombstone on its own. Blobs are content-addressed and shared — two records with identical content hold one blob — so freeing one means proving no record points at it. That's a separate, explicit sweep:

$ gonzalo gc --root ./store
scanned:  128
freed:    3
retained: 41
deferred: 0

gc marks every blob the store still needs, from the records themselves: a record whose body is a blob, the blob a tombstone pins, every code-graph slice a view's manifest names, and every shard a vector index's manifest names. Everything else is deleted, with one exception: a blob younger than --min-age (default 1h) is kept, because a writer uploads its blobs before the manifest that names them, and a sweep in between would delete a blob that is about to be referenced (ADR 0028). Those blobs are counted as deferred, so a sweep that reclaims nothing tells you whether there was nothing to reclaim or only blobs too young to touch. They are freed by a later gc once they are old enough. --min-age takes the same spellings as collect --horizon; the one-hour default is also how long your clocks may disagree, so don't lower it below the skew between the GC host and the store. gonzalo index --gc (including under --watch) always uses the default.

A tombstone pins the blob of the record it replaced (ADR 0024). A delete is replicated, not final: while the tombstone is around, a peer that never saw the delete can still sync the record back, and re-putting the same content doesn't re-upload it. So the bytes survive exactly as long as the tombstone does, which is the horizon you already chose. Reclaiming them is three steps, in order:

  1. delete the record — the tombstone appears, holding the blob;
  2. collect past the horizon — the tombstone goes, releasing the pin;
  3. gc — nothing references the blob now, so it's swept.

Like collection, GC is explicit and never automatic: a blob swept while some peer still holds the record naming it can't be restored by a later sync.

Deleting an index, and getting it back

A graph or vector manifest is different: its body is inline JSON that names its blobs, so a tombstone that threw the body away would leave the blobs unnameable. Instead, the tombstone of a manifest keeps the body and records which kind it was, and gc marks through it. The slices or shards it names survive until the tombstone is collected, so the same three steps apply (ADR 0029). Because of that, a deleted index can be put back within the horizon:

gonzalo undelete --root ~/.gonzalo --namespace vectors --collection indexes --id docs
restored: vectors/indexes/docs
revision: {"counter":5,"hash":"…"}

The restored manifest keeps its original created time. undelete writes nothing and exits non-zero when:

  • there is no tombstone at the key (never existed, or already collected);
  • the record is live;
  • the tombstone did not keep a body: only manifest kinds can be undeleted;
  • a blob the manifest names is missing, for example one that another deleted and collected index also used. The message names the hashes;
  • the key was re-created since the delete.

A write that would create a record over a manifest tombstone is refused, so the restore window cannot be destroyed by accident (ADR 0030). That covers reopening a deleted vector index and running gonzalo index against a deleted graph manifest. The write fails with an invalid-argument error (HTTP 400 over the daemon) naming the two ways out: gonzalo undelete to restore the manifest, or gonzalo purge to discard it. After gonzalo reset, indexing again therefore fails until you do one of those for each manifest key. gonzalo index --watch logs the refusal as a failed re-index on every debounce tick and reconcile, and keeps retrying until the key is purged or undeleted.

Note. RecordVectorIndex::open refuses a deleted index outright, naming the key and the same two ways out (gonzalo#340) — so the call that was wrong is the one that fails, rather than a later commit. A commit refused on a handle that was already open when the delete landed can still leave one orphaned shard blob, which the next gonzalo gc reclaims.

Warning. Upgrade every binary that runs gonzalo gc against a store before you rely on this pin. An older binary reads a tombstone that carries deleted_kind without complaint, but it does not mark through the retained body, so its gc frees the very shards the tombstone protects, and a vector index's shards cannot be regenerated. Sync copies records, not blobs, so peers with separate blob stores are not exposed; the risk is one store root run by two binary versions.

If two peers delete different revisions of the same manifest, sync keeps the newer tombstone and drops the other, so shards that only the older revision named lose their pin early. The surviving tombstone is still a complete manifest. If the two tombstones sit at the same counter, each peer keeps its own and they do not converge on one body; see ADR 0029.

Discarding a tombstone: gonzalo purge

To give up on a deleted manifest, and let a new index be created at its key, remove the tombstone for that one key:

gonzalo purge --root ~/.gonzalo --namespace vectors --collection indexes --id docs
purged: vectors/indexes/docs
revision: {"counter":4,"hash":"…"}

This discards the restore window, so there is no prompt and no --yes; run undelete first if you might want the index back. purge refuses a record that is live (exit 1), because removing a live record would leave no tombstone for a peer to respect and the record could come back on the next sync. Use gonzalo delete for that. A key that does not exist is also an error (exit 1), unlike delete, which is idempotent. Over a daemon the purge route needs an admin token and makes the same live-record check, answering 400 (gRPC InvalidArgument); the two surfaces agree (ADR 0031). The daemon's check is narrower in one way that matters only to collection: a purge naming a revision that no longer matches a live record — the key was recreated while collection held the old tombstone revision — still answers 409 Conflict and removes nothing, so collect records the conflict and carries on.

Ancestor cap

Each record keeps a short list of the revisions it came from, so sync can tell "this side is just behind" apart from "both sides changed". The list is capped at 32 entries by default. To change it, pass --ancestor-cap <n> to gonzalo delete, undelete, reset, collect or sync, or set the GONZALO_ANCESTOR_CAP environment variable for gonzalod, which has no command-line flags. The cap must be at least

  1. Most deployments never need to change it.

A record edited more times than the cap since two stores last synced is treated as changed on both sides: sync merges it or reports a conflict, but never silently overwrites. Peers with different caps work together correctly.

Git stores: resolve pull conflicts before pushing

If pull reports conflicts, don't push until you've resolved them. A delete that conflicts with a remote edit (or an edit that conflicts with a remote delete) keeps your local side in the merge commit, so pushing would publish your live record over the other side's delete.

Over the daemon

gonzalod serves the same operations. Existing record routes keep their meaning, with tombstones hidden:

OperationHTTPPermission
Delete (writes a tombstone; deleter as above)DELETE /v1/records/{ns}/{col}/{id}write on the namespace
Raw read of one recordGET /v1/raw/records/{ns}/{col}/{id}read on the namespace
Raw write (replication; revision stored verbatim, never re-stamped)PUT /v1/raw/records/{ns}/{col}/{id}admin
Raw key listingGET /v1/raw/keys?namespace=&collection=read on the namespace; read on * without namespace
Purge (physical removal)POST /v1/purge/{ns}/{col}/{id} with body {"expected": <revision>}admin

gRPC has the matching GetRaw, ListRaw, PutRaw and Purge RPCs, with the same permissions.

collect and purge through a daemon need an admin token; a non-admin token fails on the first eligible tombstone, before anything is purged.

Who a replicated record is attributed to. A raw write never changes a record's revision, and it always keeps the author the record arrived with, because the route is admin-only (ADR 0031) and an admin token is the replication credential. So authorship carries across stores without any special handling. A non-admin token cannot reach the route at all: it gets 403 (gRPC PermissionDenied) and nothing is written.

Writes to a deleted key. A conditional write (normal or raw) whose expected revision the daemon rejects as not found returns HTTP 412 (gRPC FailedPrecondition), and the Rust client reports it as not found. A conditional normal write over a deleted key is the common case. This used to be an opaque HTTP 500. It is not a 404, because on the raw routes a 404 means the daemon is too old to have them.

Purge requires admin because it is the one operation that can make a delete undoable across peers. The raw write requires admin for the same reason: it writes verbatim, so it reaches past the guard that protects a deleted manifest's restore window, and a namespace writer could otherwise discard that window by aiming a fresh record at the tombstone's — entirely predictable — revision (gonzalo#342).

Blob GC is not a daemon route. gonzalo gc sweeps a filesystem store directly. A library caller can run gc_blobs against any store that is both a Store and a BlobStore, a daemon-backed one included, but the token then needs read on * (the sweep lists every key, tombstones included) and read/write on _blobs. A token scoped to one namespace would mark a partial view of liveness and sweep the rest — which is why the mark set is never a caller's to supply.

Fleet access-control records

Gonzalo stores the identity, role and audit state for Ariel, the caliban-ai fleet's chat bridge, and for other consumers that share the same model (prospero's API among them). Six typed views in gonzalo-domain's fleet module, re-exported by the gonzalo facade as fleet::*, cover a person, the external accounts bound to that person, role grants, per-channel configuration, one-time link tokens, and the audit trail. The design is recorded in ADR 0022, whose ChannelConfig shape and repo-scoped grant variant were amended by ADR 0023.

The six kinds

KindFor
PersonA human with fleet roles, keyed by an opaque id the minting consumer generates — never an email or a platform id.
IdentityBindingTies one external account (a Discord user, an OIDC subject, …) to a Person.
RoleGrantOne person's role for one scope (the whole fleet, or one workspace). Revoking a role is a delete, not an edit.
ChannelConfigA chat channel's configuration: what it follows, how it's notified, and the highest role a command in it may run with.
LinkTokenA one-time, expiring token that lets a person claim an external account.
AuditEntryOne write-once record of who did what, to what, when, and with what result.

Two namespaces

Records live in two namespaces:

  • fleet holds the mutable access-control state: people, identity bindings, role grants, channel configs and link tokens.
  • fleet-audit holds the audit trail.

They're kept apart because ADR 0015 scopes daemon auth per namespace: a dashboard or auditor token can be granted read on fleet-audit without also being able to read link tokens or role grants, and a writer that only appends audit entries needs no write access to the access-control state itself.

Key shapes

Composite ids are built only by each view's key helpers, which escape every component so an id stays unambiguous even when a component (an OIDC issuer, say) itself contains :.

KindKey
Personfleet/people/<person_id>
IdentityBindingfleet/identity-bindings/<authenticator>:<subject>
RoleGrantfleet/role-grants/<person_id>:<scope>
ChannelConfigfleet/channels/<provider>:<tenant>:<channel>
LinkTokenfleet/link-tokens/<token_hash>
AuditEntryfleet-audit/entries/<at>-<nonce>

A ChannelConfig's key includes the tenant (the guild, workspace or team id) because a channel id is only unique within its tenant on some platforms — without it, the same channel id under two different tenants would collide on one record.

Gonzalo stores; it doesn't decide

Gonzalo stores these records but never evaluates the roles in them. The consumer reads them and decides — for Ariel, that means the two-key rule, effective = min(person_role, channel_ceiling). gonzalod keeps authorizing its own API by ADR 0015's token Principal, which is unrelated to FleetRole; making gonzalod itself authorize by fleet role would need a new ADR.

  • Only a hash is stored. LinkToken.token_hash is a domain-separated blake3 hash of the token's 32 random bytes. The view has no field that can hold the secret itself, so a raw token never reaches storage or, on the git substrate, commit history.
  • Redemption marks the token consumed; it never deletes it. redeem checks the secret, that the token hasn't expired, and that it isn't already consumed, then returns a copy with consumed set, written with the read revision as expected so two concurrent redemptions produce one Committed and one Conflict. Marking instead of deleting keeps no record of who redeemed the token, unlike a tombstone.
  • A token may be deleted only after it expires. Because redemption checks expiry, a token that reappeared after deletion (say, resurrected by a sync with a peer that never saw the delete) would already be useless — so deleting before expiry is not offered.

Audit entries are write-once

An AuditEntry is created with put(record, None) and never updated. A Conflict means the key's nonce collided; the consumer retries with a new nonce. Retention uses the ordinary reset and collect on fleet-audit, the same as any other namespace.

No person is ever resolved by email or handle

The platform user id or OIDC sub — IdentityBinding.subject — is the only lookup key. A username (handle) and an IdP-asserted email are both optional snapshots, kept for display only. Person.email is likewise an optional, operator-set contact address, never used to match an account to a person. An account joins a person only through a redeemed link token or an explicit operator action, which the identity binding records — handles are renamed and re-registered, and some platforms return unverified emails, so neither is safe as a lookup key.

Merge behaviour

  • The four configuration kinds — Person, IdentityBinding, RoleGrant and ChannelConfig — are Structured. With a real common base (git's merge base under pull, or a retained shared parent under sync_with_ancestry), separate field edits merge and the same field edited two ways conflicts.
  • LinkToken and AuditEntry are Opaque. A LinkToken redemption descends from the revision it read, so sync fast-forwards a peer that still holds the unredeemed token; only two independent redemptions of one revision diverge, and that's surfaced. An AuditEntry is written once under a unique key, so any divergence means a key collision or tampering, and is surfaced rather than merged into a body that wouldn't decode.
  • Composite fields are stored as one-element arrays so they merge as a single unit. An externally-tagged actor/origin enum (bound_by, granted_by), a verified-email pair, a RoleGrant's scope, and a ChannelConfig's follows are all wrapped this way, so a concurrent change can never mix one side's enum variant (or one field of a pair) with the other's into a body that fails to decode.
  • Plain sync has no common base. Unlike pull and sync_with_ancestry, plain sync doesn't retain a shared ancestor, so under it every concurrent edit to one of the four Structured kinds — not just the atomically-wrapped fields — surfaces as a conflict rather than merging field by field. Choose pull or sync_with_ancestry when field-level merging of fleet records matters.

See Storage backends and Deletion, reset & collection for how merge classes, sync and tombstones work in general.

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

Added

  • gonzalo purge removes one tombstone, discarding its restore window. It refuses a live record and an absent key (exit 1), takes no prompt, and prints the key and revision. (ADR 0030, #333)
  • Deleting a manifest is now recoverable. Record gains an additive deleted_kind field (tombstones only; absent on every existing record). A GraphManifest or VectorManifest tombstone now retains the deleted body instead of discarding it, and gonzalo gc marks through that body, so a deleted index's slices or shards survive until collect removes the tombstone. gonzalo_core::undelete, the gonzalo undelete subcommand and the facade re-export restore a manifest from its tombstone, preserving meta.created. A put that creates over a manifest tombstone is refused (see below). An older binary's gc does not read deleted_kind and frees the shards the tombstone pins, so upgrade every binary that runs gc against a store first. (ADR 0029, #327)
  • BlobStore::has_blob. A defaulted method (get_blob(..).is_some()), overridden by the filesystem store with try_exists, by S3 with HeadObject, and by the remote store with the presence route below. A failure that is not "no such key" is an error, not false. (#325, #329)
  • HEAD /v1/blobs/{hash} and the HasBlob gRPC RPC answer whether a blob exists without reading it, and ServerStore::has_blob now uses them instead of the downloading default. A writer confirming its referenced blobs survived a commit previously pulled every one of them over the daemon — 600 KB a shard for a vector index, on every interactive upsert. Additive on both surfaces. Two notes for existing callers: HEAD /v1/blobs/{hash} already answered 200/404, because axum serves HEAD from the GET handler and discards the body; it now reports content-length: 0 rather than the blob's size, since a size needs the read the route exists to avoid. GET the blob if you need its length. Over gRPC a daemon with no HasBlob RPC earns DAEMON_PREDATES_BLOB_PRESENCE ("upgrade gonzalod") rather than a silent fall back to downloading, which would keep paying the cost with nothing to show why; over HTTP no such message is needed, because axum serves HEAD from a route's GET handler and an older daemon therefore answers anyway. (ADR 0028, #329)
  • A sweep policy for blob GC. SweepPolicy, DEFAULT_MIN_AGE (one hour), sweep_blobs_with and gc_blobs_with let a caller set the minimum blob age explicitly; gc_blobs and sweep_blobs keep their signatures and use the default. GcReport gains deferred, the unreferenced blobs held back for being too young, and freed now comes back in hash order. (#325)
  • gonzalo gc --min-age <duration> (default 1h, same spellings as collect --older-than: one positive whole number and one unit of d, h, m or s). The gc and index --gc summaries print deferred. (#325)
  • Vectors now survive the process that built them. A new RecordVectorIndex persists a VectorIndex behind sharded, content-addressed blobs named by a new RecordKind::VectorManifest record (MergeClass::Opaque, since caller-supplied vectors can never be re-derived the way a code-graph manifest can). open(store, key, space, dim) declares an embedding space and dimension and errors naming both values on a mismatch, at open and again on every OCC conflict, so two handles racing to create the same missing index can't silently mix models. Shard assignment (shard_of) and the default shard count (DEFAULT_SHARDS, 256) now take std::num::NonZeroU16, so a zero shard count is unrepresentable rather than a runtime panic waiting to happen. gonzalo gc marks a vector manifest's shard blobs as live, the same way it already marks a graph manifest's slices, so running GC against a store holding a durable vector index is safe when no writer is mid-commit. A sweep that lands between a write's shard upload and its manifest commit used to delete the new shard and leave the index unopenable; see the grace-period fix under Fixed for how that is now narrowed, and what remains. A durable index's writes commit under optimistic concurrency, retrying up to 5 times on a conflict. Each commit is checked against the manifest revision the writer's in-memory state came from, so two writers on the same shard cannot silently overwrite each other; and a conflict reloads every shard that changed, not only the ones the write touched, so a writer that lost a race cannot later overwrite the winner's vectors from stale memory. The gonzalo facade's vector module (behind the vector feature) re-exports RecordVectorIndex, DEFAULT_SHARDS, shard_of and VectorManifest, so a facade consumer can reach the durable index without depending on gonzalo-vector/gonzalo-core directly. See ADR 0027. (#323)
  • KnowledgeStore::open rebuilds chunk counts from a durable index. KnowledgeStore.chunk_counts only ever lived in memory, so a re-ingest of a shrunk record after a restart used to leave its high-ordinal chunks behind forever once the index itself became durable. open(store, index, embedder) scans the index once via the new VectorIndex::keys (below) to rebuild counts at construction; KnowledgeStore::new is unchanged and still starts counts empty, which is correct for a fresh in-memory index. (#323)
  • Records carry their times. Meta.created and Meta.updated were always 0; the store now stamps both inside the same critical section that decides the write, exactly as it stamps a tombstone's deleted_at. created survives every edit, a delete counts as the record's last update (its updated equals the tombstone's deleted_at), and a recreation over a tombstone starts a new created, because that is a new record at an old key. A replication write keeps the source's times, so syncing a record is not an edit. A client's values are overwritten: times a caller can set are times a caller can lie about. The conformance suite asserts all of it, so every substrate agrees. (#293)
  • Builders for the common record paths. Record::create makes a record that doesn't exist yet, and record.update(body, meta) makes the next version of one you read: it sets the revision to follow and parent to the revision it was built from, which is the pairing OCC depends on and the easiest thing to get wrong by hand — a mismatch doesn't fail at build time, it surfaces later as a spurious conflict. Meta::new(author, origin_system) covers the provenance. Fields stay public, so nothing existing breaks. (#305)
  • Blob garbage collection, and tombstones pin their blobs. gonzalo gc now marks against every record in the store instead of a caller-supplied list of manifests: a record whose body is a blob, the blob a tombstone pins, and every code-graph slice a manifest names. A tombstone carries the deleted record's blob hash in the new Record.deleted_blob, so a delete never destroys content a peer can still sync back, and re-putting the same content doesn't re-upload it. Reclaiming a deleted record's bytes is three deliberate steps: delete, collect past the horizon, then gc. gc_blobs takes the store and does its own listing; sweep_blobs and live_blob_hashes are the halves, for a caller that already knows its live set. See ADR 0024. (#292)
  • Records over MCP, and an MCP server that can talk to a daemon. gonzalo-mcp exposed only the code graph, so an agent could ask what calls a function but could not read the records the store exists to hold — the gap both the Zep and Letta parity matrices meant by "no MCP surface", while their rows wrongly claimed there was no MCP server at all. It now also exposes record_get and record_list, with record_put and record_delete behind --allow-writes (or GONZALO_MCP_ALLOW_WRITES=1) and absent from the advertised tool list without it, so an agent never sees a tool it cannot call. GONZALO_DAEMON (or --daemon <url>, with GONZALO_TOKEN) points the server at a running gonzalod instead of a local directory, so it can share a store rather than answer from one machine's copy. An absent key is an error naming it, never an empty result, for the same reason an unknown view is. Vector and knowledge recall over MCP are tracked separately (#317). (#197)
  • Published schema artifacts, so non-Rust clients are generated rather than maintained. Client SDKs are 🔴 in all three competitor matrices, each suggesting the same fix: generate one from the daemon's schema. But gonzalo-proto was an internal workspace crate and the HTTP/JSON surface had no description at all, so the daemon's non-Rust promise (ADR 0007) was unfulfilled at the packaging layer. Every release now attaches gonzalo-vX.Y.Z.proto and gonzalo-openapi-vX.Y.Z.json to its GitHub Release, and the OpenAPI document lives in-tree at docs/api/openapi.json. They cannot drift: SERVED_OPERATIONS is checked against the router's own source and against the document, in both directions, so an operation added without describing it fails CI and so does one described but not served. The guide gains a Generating a client chapter with a worked Python example. No client code in any other language enters this repo (ADR 0020). (#198)

Changed

  • BREAKING: RecordVectorIndex::open refuses a deleted index. Opening an index whose manifest is tombstoned used to succeed, hand back a handle onto an empty in-memory index, and fail only at the first commit — so an operator who deleted an index and restarted saw open succeed, queries return nothing, and the real explanation arrive on the next upsert. open now reads raw and errors with CoreError::Invalid, naming the key and the two ways out (gonzalo undelete, gonzalo purge). The trigger is the same condition plan_put uses — the stored tombstone's deleted_kind naming a manifest kind — so open refuses exactly when the first commit would have, and a tombstone with no restore window (written before #327) still opens fresh rather than stranding the key. There is deliberately no flag to skip the refusal: a caller starting over purges first, which keeps discarding a restore window one explicit operator action. One side benefit: a refused reopen now stages no shard blob, where the commit-time refusal left one orphan for gc. (ADR 0029, ADR 0030, #340)

  • BREAKING: the raw replication write requires an admin token. PUT /v1/raw/records/{ns}/{col}/{id} and the PutRaw RPC authorized write on the namespace; they now require admin, matching POST /v1/purge/... and Purge. A replication peer configured with a non-admin token begins getting 403 / PermissionDenied and must be re-credentialed — ADR 0015 already described an admin token as the replication credential, so a correctly configured peer is unaffected. The route writes verbatim and so reaches past the plan_put guard added above, which let a principal with plain Write discard a deleted manifest's restore window by aiming a fresh record at the tombstone's revision — predictable, because tombstone_hash() is a constant. Authorship now carries across stores unconditionally, since the non-admin restamping that hardened the old path is no longer reachable. (ADR 0031, #342)

  • BREAKING: the daemon refuses to purge a live record. POST /v1/purge/... and the Purge RPC now answer 400 / InvalidArgument when the named revision matches a live record, as gonzalo purge has since #333; purging a live record leaves no tombstone, so a peer that has not synced since would resurrect it (ADR 0021). An admin purge of a live record therefore starts failing where it previously succeeded; use gonzalo delete and then purge the tombstone. Tombstone collection is unaffected: the refusal is narrow enough that a purge naming a revision that no longer matches — the key was recreated while collection held the old tombstone revision — still answers 409 Conflict and removes nothing, which collect records and carries on from. (ADR 0031, #341)

  • A put that creates over a manifest tombstone is now refused. A consumer create (expected: None) at a key holding a GraphManifest or VectorManifest tombstone used to succeed, overwriting the tombstone and discarding the retained body and the shard pin. It now fails with CoreError::Invalid (HTTP 400 / gRPC InvalidArgument), naming gonzalo undelete and gonzalo purge. This affects RecordVectorIndex commits and gonzalo index over a deleted manifest, so gonzalo reset followed by gonzalo index now needs a purge or undelete first. Replication writes and undelete are unchanged. RecordVectorIndex::open still succeeds and fails at its first commit (gonzalo#340), and the daemon's admin purge route does not check for a live record (gonzalo#341). (ADR 0030, #333)

  • BREAKING: gonzalo_core::unreferenced_slices and gonzalo_core::live_slice_hashes are removed. After #325 rewrote the sweep, neither had a caller outside its own unit tests — live_blob_hashes inlines the manifest-entry logic rather than calling live_slice_hashes, and sweep_blobs_with does its own retain loop. Both were also footguns: unreferenced_slices is age-unaware, so mapping list_blobs() down to hashes and feeding it rebuilt the unsafe delete list #325 removed, and live_slice_hashes used alone as a mark set keeps only manifest slices and sweeps every record body. Use gc_blobs_with / sweep_blobs_with to sweep and live_blob_hashes to mark. The gonzalo facade never re-exported either, so only a direct gonzalo-core dependant is affected. (#331)

  • BREAKING: the indexing knobs move into gonzalo_cli::IndexOptions. index_with_gc_filtered and index_with_gc_filtered_worker are replaced by index_with_options(root, src, repo, view, &IndexOptions), which carries {gc_after, min_age, filter, worker}. The eight-argument worker variant needed #[allow(clippy::too_many_arguments)] and respelled every argument at each fan-out layer, so a new knob cost six edit sites; it is now a field. index_with_gc is unchanged. IndexOptions::default() deliberately sets min_age to DEFAULT_MIN_AGE rather than the derived Duration::ZERO — a zero default would let a caller who sets gc_after reinstate the age-unaware sweep ADR 0028 removed. (#331)

  • BREAKING: IngestError::Conflict is a boxed tuple variant. It was Conflict { key, expected, current } with the fields inlined, which made the error path of every Result in gonzalo-ticket's ingest module 136 bytes wide — clippy 1.99's result_large_err. It is now Conflict(Box<gonzalo_core::Conflict>), the same shape PutResult::Conflict already returns, so the ingest path stops unboxing and re-inlining and callers get the whole conflicting record rather than its revision alone. Code that matches on the struct variant's fields must match the boxed value instead. (#336)

  • The pinned Rust toolchain is 1.99.0. rust-toolchain.toml moved from 1.95.0, four releases behind. Getting the gate green on it needed five fixes across four crates, each hidden behind the previous because clippy stops at the first failing crate: async-trait 0.1.89 → 0.1.92 (upstream stopped emitting the #[must_use] that made clippy's double_must_use fire on 14 desugared async fns in gonzalo-core — preferred to an #[allow], which would also have masked the lint for hand-written code, and it pulls in syn 3.0.6); as_chunks::<2>() for chunks_exact(2) in gonzalo-domain's hex parser; the IngestError boxing above; a result_large_err allow scoped to gonzalo-proto's generated v1 module, where tonic's Result<_, Status> signatures are build-script output and not editable; and slice::fill for a manual loop in gonzalo-soak's oracle tests. (#336)

  • BREAKING: BlobStore::list_blobs returns Vec<BlobEntry>. Each entry carries the blob's hash and modified_unix_ms, which the sweep needs to tell a freshly uploaded blob from an abandoned one. Any out-of-tree BlobStore implementor must change its signature. (#325)

  • BREAKING: the daemon's blob listing returns objects. HTTP GET /v1/blobs returns [{"hash": …, "modified_unix_ms": …}] instead of ["…"], and gRPC ListBlobsResponse reserves field 1 and adds repeated BlobEntry entries = 2, so an old gRPC client sees an empty list and a GC through it deletes nothing. docs/api/openapi.json gains a BlobEntry schema; note that the #198 drift check compares paths and methods, not response schemas, so this change is not guarded by it. See ADR 0028. (#325)

  • BREAKING: VectorIndex gains a required keys method. There was no way to enumerate an arbitrary index's contents, which is what KnowledgeStore::open needs to rebuild derived state — its per-record chunk counts — after a restart (gonzalo gc never calls keys; it marks a vector manifest's shard blobs directly). keys has no default implementation and must be added by any out-of-tree VectorIndex implementor. upsert_many was also added, but as a default that loops over upsert, so it is not itself breaking — RecordVectorIndex overrides it to batch a bulk load into one manifest commit instead of one per vector, and the new impl VectorIndex for Arc<T> forwards both methods (including upsert_many, explicitly, so a shared Arc handle doesn't silently fall back to the looping default). See ADR 0027. (#323)

  • gonzalo-vector now requires a tokio runtime. RecordVectorIndex::open reads shard blobs concurrently via tokio::task::JoinSet::spawn, which panics outside a tokio runtime. tokio (feature rt) moved from a dev-dependency to a real one. Every gonzalo binary already runs on tokio, so nothing breaks today, but a future non-tokio consumer of gonzalo-vector would need one to call open. (#323)

  • BREAKING: the facade's surface is grouped into modules. gonzalo re-exported 74 names flat, many of them ordinary nouns — Actor, State, Provider, Container, Link, Topic, Session, Priority, Person — which collided with a consumer's own types and said nothing about which layer they came from. The flat surface also could not express two layers using one word: gonzalo-ticket and gonzalo-graph both define a Page, and only one of them could ever be re-exported. The root now holds the record and store core and nothing else; every domain type lives under the module naming its domain, mirroring the structure gonzalo-domain already had. There is no deprecation window — the flat names are gone in this release — so the mapping is below. See ADR 0026. (#319)

    wasnow
    gonzalo::MemoryTier, Topicgonzalo::memory::{MemoryTier, Topic}
    gonzalo::Session, Turngonzalo::session::{Session, Turn}
    gonzalo::Checkpointgonzalo::checkpoint::Checkpoint
    gonzalo::RecordCodecgonzalo::codec::RecordCodec
    gonzalo::Ticket, TicketBody, TicketEvent, State, StateCategory, Actor, ActorRole, Priority, PriorityLevel, Resolution, Provider, Container, Link, LinkKind, LinkTarget, BodyFormatgonzalo::ticket::*
    gonzalo::TicketSource, Capabilities, Cursor, FieldMapping, InMemorySource, Page, SourceError, StateMapping, StateSignal, record_key, scoped_uidgonzalo::ticket::*
    gonzalo::GitHubSource, JiraSource, LinearSource, GitLabSource, AsanaSourcegonzalo::ticket::*
    gonzalo::Person, RoleGrant, IdentityBinding, ChannelConfig, LinkToken, AuditEntry, AuditResult, Authenticator, BindingOrigin, Consumption, EmptyFollowSet, FleetActor, FleetKeyError, FleetRole, Follows, GrantScope, LinkSecret, LinkSecretError, NotifyPreset, RedeemError, VerifiedEmailgonzalo::fleet::* (already available there)
    gonzalo::CodeGraph, GraphStore, InMemoryGraphStore, Located, Reference, Symbol, SymbolKind, assemble, build_rustgonzalo::graph::*
    gonzalo::Embedder, Match, MemoryVectorIndex, VectorIndexgonzalo::vector::*
    gonzalo::Hit, KnowledgeStore, knowledge_textgonzalo::knowledge::*

    Unchanged at the root: Record, RecordKey, RecordKind, Revision, Body, Meta, Identity, ContentHash, KeyPrefix, Store, BlobStore, AncestryStore, PutResult, DeleteResult, Conflict, CoreError, Result, MergeClass, MergeOutcome, sync, sync_with_ancestry, merge, collect, reset, reset_as, gc_blobs, now_ms, the reports, and FsStore / GitStore / S3Store / ServerStore.

  • S3 listings stop paying a read per record. Hiding tombstones from list cost a GetObject per key, because a tombstone lives at the deleted record's own object key and only its body says so; reset and collect inherited it. A delete now also writes a small marker beside the record, so one ListObjectsV2 pass classifies every key and only marked keys are read — one read per tombstone, which collect bounds, instead of one per record, which nothing did. Over RustFS with 500 live records and 200 tombstones, a listing went from 700 reads (216 ms) to 200 (110 ms), and to none at all (40 ms) once the tombstones were collected. The record object does not move, so every compare-and-swap is still a single conditional write. A bucket written by 0.7.0 upgrades itself: until a collection is flagged, list reads every key exactly as before and backfills the missing markers as it goes, so there is no admin step and no window where a deleted record could reappear. See ADR 0025. (#294)

Fixed

  • An S3 HeadObject fault was described as "unhandled error". A failed blob presence check surfaced HeadObjectError's own Display, which renders the unmodelled variant with no code and no status — so the most common misconfigurations, a 403 from a bucket policy or wrong credentials, were indistinguishable from each other and from anything else. The error now leads with the service's error code and the HTTP status, read off the response before the SDK discards it, and names the blob. (#330)

  • KnowledgeStore::ingest committed once per chunk on a durable index. ingest looped upsert per chunk. On MemoryVectorIndex that is cheap; on the RecordVectorIndex added in #323 every call is a full OCC commit — re-serialise the shard, upload it as a blob, write a new manifest revision — so ingesting one three-chunk record advanced the manifest three times and left two superseded shard blobs for gc. At the default 256 shards and ~100k vectors a shard is ~600 KB, so that was ~1.8 MB of blob writes for one record. It is now one upsert_many per record. One behaviour change falls out: every chunk is embedded before anything is written, so a failing embedding now writes nothing, where before the earlier chunks were already committed. Orphaned chunks (from a record that shrank) are still removed one at a time; batching them needs a new VectorIndex method, which cannot be called from gonzalo-knowledge until the workspace version is bumped, because cargo publish --dry-run verifies each packaged crate against the published gonzalo-vector. (#326)

  • A sweep could delete a blob between a writer's upload and the manifest commit naming it. Both manifest writers upload blobs before committing, so a gonzalo gc (or gonzalo index --gc under --watch, which needs no operator) landing in between deleted the blob; for a vector shard that left the index unopenable and unrecoverable. A sweep now keeps unreferenced blobs younger than the minimum age, and each writer re-checks the blobs it newly referenced after its manifest commits and re-uploads any that are gone. This narrows the race rather than closing it: a writer that crashes between its commit and that re-check, or a live writer whose old blob a sweep had already decided to delete, can still lose it, and the minimum age is also the clock-skew tolerance: a GC host running ahead of the store defeats it. See ADR 0028. (#325)

  • gonzalo gc no longer deletes live records' content. The mark set was built from graph manifests alone — the only blob references that existed when it was written — so on any store holding a record whose body is a blob, a sweep deleted that record's bytes while the record still pointed at them. The dangling reference surfaced later, as a failed fetch, with nothing to tie it back to the sweep. GC now marks from the records themselves, and an undecodable manifest is an error rather than an empty reference set. (#292)

  • S3 hardening, before any backend other than RustFS is qualified. Four fixes from the #203 review, none of which affected the pinned RustFS: an ambiguous commit (the write applies, the response times out, the retry sees 412) no longer comes back as a Conflict for the caller's own write; a GetObject that returns no ETag is now an error instead of an empty If-Match, which a permissive server could read as "no condition" and turn every compare-and-swap into a blind overwrite; a 409 retries after a jittered, bounded delay rather than immediately, so a burst cannot spend all eight attempts inside one in-flight window; and consumer list reads keys in bounded batches instead of one at a time. The soak's bucket is now created with the maintained AWS CLI image — MinIO stopped publishing mc, which had already broken CI once. (#286)

  • Git pull honours ancestor ordering, like sync. For a record changed on both sides, pull went straight to comparing kinds and bodies, so a remote that had simply advanced past the local revision — the normal state after a peer received the record through sync and wrote again — was reported as a divergence. A remote delete came back as a delete-versus-edit conflict that kept the deleted record alive, and an advance of an Opaque body, a checkpoint say, conflicted too, while sync fast-forwarded both. Pull now checks ancestry first and takes the side that is ahead, naming those keys in the new PullReport.advanced. Nothing was ever lost — the old behaviour failed safe and kept the local side — but pull and sync disagreed about the same pair of stores. (#289)

  • The git store writes record files atomically. It wrote with std::fs::write, which truncates the file before writing it, while get and list read the working tree without the repo lock. A read that landed mid-write saw an empty or half-written record and failed with a serialization error, and a crash between the truncate and the write left a zero-length record. Since 0.7.0 a delete writes a tombstone file too, so deletes had the same exposure. A write now goes to a temp file that is synced, renamed over the record and followed by a directory fsync, as the fs store already did, so a reader sees the old record or the new one and never a torn file. (#283)

Docs

  • The container guide covers scoped auth. docs/container.md listed GONZALO_AUTH_FILE but never said what goes in it. It now documents the mode precedence, the principals file with an example, the _blobs namespace for blob access, the 401/403 responses on both transports, and how writes are stamped with the principal's name. It also lists GONZALO_ANCESTOR_CAP. (#281)
  • The gonzalo crate docs have runnable examples. The docs.rs page for the crate consumers depend on was a bare list of re-exports; the only walkthrough lived in a private test that never reached the published docs. It now opens with four doctests: store and read a record, update it conditionally and handle the conflict, delete it and watch the delete survive a sync, and map a typed view through RecordCodec. They run in CI, so they cannot drift from the API. (#307)

0.7.0 - 2026-09-15

Deletion replicates. A delete used to be local: sync met a peer that still held the record and copied it straight back, so a deleted record returned on the next run and a namespace-wide reset was unusable on anything replicated. This release makes a delete a write of its own — a tombstone that travels like any record — and builds namespace reset and horizon-based collect on top of it. It also adds fleet access-control records, the identity, role, channel and audit state the Ariel chat bridge keeps in gonzalo.

Upgrade every gonzalo binary together. Deletion now replicates, and it does so with a record kind older binaries can't read. A 0.6 gonzalo, gonzalod, or any program built on gonzalo-core 0.6 that reads a store holding a tombstone fails on that key with a serialization error: loud, and no data is lost. The silent failure is worse: a 0.6 binary that runs sync against 0.7 data can't see tombstones and copies deleted records back, and nothing on the new side can tell that apart from a genuine recreation. Upgrade every binary that reads a store directly, and every binary that runs sync, at the same time. A 0.7 client against a 0.6 gonzalod keeps working for normal reads and writes, and fails replication reads with an explicit upgrade gonzalod error rather than falling back. See the guide's "Deletion, reset & collection" page and ADR 0021. (#203)

The new fleet access-control record kinds need the same all-at-once upgrade: a binary built before them fails to decode those records. Upgrade every gonzalod, CLI and embedded consumer before any writer uses them. (#278)

Added

  • Replicated deletion. A delete writes a tombstone (RecordKind::Tombstone) at the record's own path, and sync and git pull propagate it, so a delete on one store sticks everywhere instead of coming back on the next sync. Tombstones carry deleted_at (ms since the Unix epoch), and records carry a bounded ancestors list. Both are optional fields, so live records written by 0.7 still read on 0.6. See ADR 0021. (#203)

  • gonzalo delete --namespace <N> --collection <C> --id <I> [--expected <REVISION_JSON>] [--root <DIR>] [--ancestor-cap <K>]: delete one record by writing a tombstone attributed to gonzalo-cli.

    • --expected is the revision object from gonzalo get's JSON output (e.g. {"counter":3,"hash":"…"}).
    • On success, including an absent or already-deleted record, prints deleted: N/C/I and exits 0.
    • On a stale --expected, prints conflict: N/C/I and current: {…} and exits 3.
    • Malformed --expected exits 2.

    (#203)

  • gonzalo reset --namespace <N> [--collection <C>] [--root <DIR>] [--ancestor-cap <K>]: tombstone every live record in a namespace or collection. It's not atomic but it's safe to re-run. Prints X deleted, M conflicts, plus one conflict: <ns/col/id> line per conflict on stderr. When there are conflicts, it also prints a final re-run \gonzalo reset` to delete the M conflicted record(s)line on stderr. Exits0with no conflicts and3with any (re-run to finish). Omitting--namespaceis a usage error (exit2`). (#203)

  • gonzalo collect --older-than <DURATION> [--namespace <N> [--collection <C>]] [--root <DIR>] [--ancestor-cap <K>]: physically purge tombstones older than a horizon you choose.

    • <DURATION> is a positive integer plus exactly one unit of d, h, m or s (e.g. 30d), with no default. Zero, a missing, compound or unknown unit, or overflow exits 2, as does --collection without --namespace.
    • It prints horizon:, purged:, unstamped: and conflicts: lines, and exits 0 even with conflicts.
    • Purging a tombstone before every peer has synced lets the deleted record come back from that peer.
    • Tombstones with no deletion time are never collected, and clock skew can only delay collection.
    • The guide has a section on choosing a horizon.

    (#203)

  • Common to delete, reset and collect:

    • --root <DIR> (default ., expands a leading ~) and --ancestor-cap <K> (default 32). 0 exits 1 with ancestor cap must be at least 1.
    • Exit codes: 0 success, 1 error, 2 usage error, 3 conflict (delete and reset). Each command's --help lists its codes.
    • The CLI operates on a local fs store only.

    (#203)

  • Replication surface on Store: get_raw and list_raw show tombstones; put_raw(record, expected) stores a record with its revision verbatim and never re-stamps the revision (through the daemon, a non-admin caller still becomes the author, while admin and open mode keep the replicated author); purge(key, expected) is the only physical removal left. (#203)

  • Store::delete_as(key, expected, author) records who deleted a record as the tombstone's author. The daemon stamps Principal::delete_author(claimed): a non-admin is always stamped as itself; an admin keeps a named deleter, or is stamped as itself when there is none; a daemon without auth keeps a named deleter, or stamps none when there is none. The HTTP DELETE body and the gRPC DeleteRequest gain an optional author / author_json claim, which ServerStore::delete_as sends. CLI delete and reset both stamp gonzalo-cli. Library reset, and the provided delete it uses by default, keep each record's own last author instead; reset_as names a deleter, the same way delete_as does. (#203)

  • Daemon routes:

    • GET /v1/raw/records/{ns}/{col}/{id} and GET /v1/raw/keys (read on the namespace; read on * when unscoped);
    • PUT /v1/raw/records/{ns}/{col}/{id} (write on the namespace);
    • POST /v1/purge/{ns}/{col}/{id} (admin);
    • the matching GetRaw, ListRaw, PutRaw and Purge RPCs.

    (#203)

  • Ancestor cap: --ancestor-cap <n> on gonzalo delete, reset, collect and sync, and the GONZALO_ANCESTOR_CAP environment variable on gonzalod, set how many prior revisions a record remembers. The default is 32 and 0 is rejected. Peers with different caps interoperate. (#203)

  • gonzalo_core::{reset, reset_as, collect, ResetReport, CollectReport}: library reset (keeps each record's own author; reset_as names a deleter) and explicit tombstone collection. Both stop at the first store error, and re-running either afterward is safe. All five are also re-exported from the gonzalo facade crate. (#203)

  • SyncReport.fast_forwarded_to_a / fast_forwarded_to_b: the keys overwritten on each side because it was behind the other's ancestor chain. (#203)

  • Fleet access-control records. Six record kinds for people and their access to the fleet: Person, IdentityBinding, RoleGrant, ChannelConfig, LinkToken and AuditEntry. Typed views live in gonzalo-domain's fleet module and are re-exported by the gonzalo facade. Records sit in the fleet namespace, with audit entries in fleet-audit. A link token stores only a hash of its secret and is marked consumed when redeemed; audit entries are write-once. gonzalo stores these records but doesn't evaluate the roles in them. A channel config is keyed by provider, tenant and channel, and carries what the channel follows (the whole fleet, or a non-empty set of workspaces), a notify preset of all, terminal or failures, and a role ceiling. Grants scope to the fleet or to one workspace. See ADR 0022 and ADR 0023. (#278)

  • SyncReport says when sync did not converge. A new unconverged field lists the keys whose write was still losing a race when sync gave up after its pass limit, and SyncReport::converged() reports whether any pass landed cleanly. Before this, a run that never won a race returned an empty conflicts list, which read as "in sync" while the two stores still disagreed. gonzalo sync prints unconverged and warns on stderr. (#290)

Changed

  • Breaking: CoreError gains Invalid. A call the store will never accept — a consumer put of a tombstone record, which must go through delete_as — now returns CoreError::Invalid instead of Backend. The daemon answers 400 (gRPC InvalidArgument) rather than an opaque 500, and ServerStore restores it as Invalid, so a store behind a daemon fails the way a local one does. Code matching exhaustively on CoreError must add the variant. (#299)

  • gonzalo sync names the keys it could not settle. It printed counts only, so an operator learned a conflict existed but not where; it now prints a conflict: <key> or unconverged: <key> line per key on stderr, as delete, reset and collect already do, and SyncSummary's conflicts and unconverged fields carry the keys rather than counts. sync --help gained the exit-code section every other command has, and migrate now names the directory it could not read. (#299)

  • The gonzalo facade re-exports BlobStore, AncestryStore, sync_with_ancestry and now_ms. Without BlobStore, Body::Blob could not be used through the facade at all, though its own docs point at the trait; without the ancestry pair, a consumer wanting ADR 0016's three-way merge had to depend on gonzalo-core directly. (#299)

  • gonzalo sync now exits 3 on conflicts or non-convergence, instead of always exiting 0. A conflict to resolve and a run that gave up before converging are both recoverable outcomes a script should notice, which is what delete, reset and collect already signal with exit 3. A sync that converges with no conflicts still exits 0. (#290)

  • Breaking: gonzalo-core's Store trait gains required methods (get_raw, list_raw, put_raw, purge, delete_as), delete becomes a provided method over delete_as, and RecordKind gains Tombstone, so this release is 0.7.0 across the workspace. The replication methods have no default implementation on purpose: get_raw = get would compile, pass every test that doesn't delete, and resurrect records in production. Third-party Store implementations get a compile error naming exactly what to add, and must implement delete_as instead of delete. (#203)

  • Sync copies one-sided records with put_raw. A delete landing on the destination between sync's read and its copy now conflicts and is resolved on the next pass. Before, the copy became a recreation and brought the deleted record back. (#203)

  • s3 retries lost conditional writes. A write that loses an If-Match race — PreconditionFailed (412), ConditionalRequestConflict (409), or NoSuchKey (the object vanished to a concurrent purge) — re-reads, re-plans and retries for up to 8 attempts before returning a backend error, so s3 reaches the same outcomes as the lock-based fs and git stores. (#203)

  • Store::delete writes a tombstone instead of removing the record. get and list hide tombstones, so applications see no difference. Deleting an already-deleted or absent key is still an idempotent Deleted. ADR 0018's local-only decision is superseded by ADR 0021, and its conflict semantics are unchanged. (#203)

  • Writing to a deleted key re-stamps the revision. A put with no expected revision over a tombstone stores the record with a revision one past the delete, so the recreation wins over the delete when synced. put still returns the stored revision; a caller that ignores it and reuses its own will get a conflict on its next conditional write. A put with any expected revision over a deleted key returns NotFound. (#203)

  • The daemon returns 412 for a write rejected as not found. A consumer PUT /v1/records/{ns}/{col}/{id} (or raw PUT) whose expected revision the store rejects with NotFound, such as a conditional write over a deleted key, now returns HTTP 412 (gRPC FailedPrecondition) instead of an opaque 500 (Internal). ServerStore maps it back to CoreError::NotFound. 404 is deliberately not used, because on the raw routes it signals a daemon too old to serve them. (#203)

  • Raw writes through the daemon keep ADR 0015's authorship rule. A put_raw from a non-admin principal has meta.author restamped to that principal, like a consumer put. An admin principal, or a daemon without auth, keeps the replicated record's author, so an admin token is the replication credential. The revision is never re-stamped in either case. (#203)

  • Sync fast-forwards when one side is simply behind. Using each record's ancestors, sync now overwrites the stale side instead of running a merge, and reports the overwritten keys in SyncReport.fast_forwarded_to_a / fast_forwarded_to_b instead of merged. Before, an Opaque kind such as Checkpoint reported a conflict even though nothing had diverged. A delete racing an edit of the same revision is reported as a SyncConflict with neither side written. Records written before 0.7 have no ancestors and take the old merge path unchanged. gonzalo sync now prints a fast_forwarded: N line, and its output labels are re-aligned to the width of that line. (#203)

  • git pull handles tombstones on paths changed on both sides: concurrent tombstones converge on the newer one, and delete-versus-edit is reported as a PullConflict that keeps local. (#203)

  • The HA soak and s3 conformance require RustFS ≥ 1.0.0-rc.6, whose conditional DeleteObject is atomic; earlier releases can let a purge remove a concurrent recreation. docker-compose.rustfs.yml pins it. (#203)

  • The HA soak adds replicated-deletion invariants. It races deletes against edits and recreations across gonzalod replicas under replica-kill chaos, and checks that acknowledged deletes survive replica kills, that no two edits commit on the same base revision and no edit commits on the same base as a delete that actually wrote a tombstone (StaleBaseCommitted), that every run observes at least one delete conflict seeded deterministically so the check doesn't depend on scheduling, that every replica agrees on each lifecycle key's deletion state, and that consumer reads match raw reads. It now also triggers on changes to gonzalo-core and gonzalo-store-server, and its timeout is 30 minutes. The tombstone conformance cases run on fs, git, s3 and the daemon client. (#203)

Docs

  • ADR 0021, superseding ADR 0018's local-only deletion decision; the "Deletion, reset & collection" guide page, with horizon guidance; the storage, daemon and CLI guide pages updated for the replication surface and authorship rules; the tombstone replication spec aligned with what shipped; the Mem0 parity matrix Delete and reset rows, both citing ADR 0021. (#203)

0.6.0 - 2026-09-10

Resolution that uses what the syntax already says, and prebuilt binaries to run it with. The code graph stops identifying every symbol by its bare name: a call site's qualifier, a definition's owning type, a receiver's written type, and a file's own imports are all recorded and consulted, so Foo::get and Bar::get stop being one node. Each rule only ever narrows — where it cannot settle a question it declines and says so, and the counts of what it declined travel with the answer.

Much of this was found by pointing gonzalo at code it was not designed against. Seven of the sixteen tickets here came from indexing a C++ engine, a Next.js app, a Node service, three Java and Kotlin course repositories and two Python projects — .h headers parsed as C, #include ignored entirely, Java and Kotlin imports unrecorded, React's wrapper idiom yielding zero symbols per file. Two of them corrected tickets written from inside this repository.

Fixed

  • The guide's binary install example no longer pins a tag. It pointed at v0.5.0, which carries no release assets — prebuilt binaries start with this release — so the documented install 404'd from the moment it was published, and pinning any concrete tag would re-create that on every release. It now reads the latest tag, with a note that archives begin after v0.5.0. (#242)

  • A skipped file now says which file. When an isolated parse worker dies on a file, the CLI reported skipping a file — parse worker error: ... without naming it. On a 2000-file tree that left no way to find the offender but bisecting by directory, which is exactly what landing #266 cost: four rounds of re-indexing subtrees to locate one 13843-line generated header that aborts tree-sitter-cpp.

    That matters more than the inconvenience. A skipped file is silently absent from the view — search finds none of its symbols, callers misses every call it makes — so the one line saying it happened has to say where.

    The message names the path, and IndexSummary carries skipped_paths so a caller can list them rather than only count them. The summary listing is bounded at five with a count of what it left out, matching how #259 bounds its extension list. No extraction change. (#270)

  • .h headers are parsed as C++, not C. .h is the conventional header extension for C++ as much as for C, and the C grammar mis-parses C++ rather than failing: enum class Color { Red }; recorded Color as a function and invented a symbol literally named class.

    Found by indexing a real C++ project (2017 files):

    beforeafter
    symbols extracted8233188385
    symbols from .h files2295129005
    symbols named class3720
    keyword-shaped names in total47026

    Those 372 were spread across 133 files and outranked every genuine symbol in the ambiguity ranking. The 6054 new symbols are the real classes, namespaces and scoped enums those headers declare — which for a C++ project is where its types and interfaces actually live.

    Safe because the two grammars agree on plain C, which is now pinned by a test comparing symbol and reference extraction under both rather than asserted. The residual 26 keyword-shaped names come from inline namespace inside a #define body, a preprocessor limitation gonzalo already documents.

    One caveat worth stating: a 13843-line generated header in that project parses under the C grammar and aborts tree-sitter-cpp. Crash isolation handled it exactly as designed — the worker died, the pool respawned, one file of 2017 was skipped and indexing continued. .h files also now report as cpp rather than c in overview's language breakdown. EXTRACTION_VERSION is 10. (#266)

  • Indexing a tree gonzalo cannot parse now says so. A file whose extension names no known language was dropped uncounted — not in files, not in skipped, not in ignored — so a repository gonzalo could not read reported every number at zero with no explanation. Querying that view afterwards returned empty answers that looked like real ones, the same failure shape as #210 and #228.

    Found by indexing a Vanderbilt machine-learning course repository whose entire content is one Jupyter notebook. Before, it printed four zeros and nothing else. Now:

    files:    0
    ignored:  0 files, 2 dirs not descended
    unindexed: 4 files, no grammar for .csv, .ipynb, .md, .txt
    note:     nothing was indexed — gonzalo parses none of the files in this tree
    

    The count is distinct from skipped (a parse worker crashed on it) and ignored (excluded on purpose), and it names the extensions responsible so the message is actionable. A file that was going to be excluded anyway stays as invisible as it already was, so ignored is unchanged and a README inside a vendored tree does not start appearing. The extra note fires only when a run indexed nothing at all, which is the case most easily misread. No extraction change, so no re-walk. (#259)

  • A JavaScript or TypeScript function wrapped in a higher-order call is now a named function. export const Controls = React.memo(() => { ... }) is the dominant React idiom, and gonzalo extracted zero symbols from files written that way: the binding's value is a call rather than an arrow, so the component had no name in the graph and every call in its body had no caller. Object properties bound to a function ({ fetchAll: () => {} }) were missed too, because the name lives in key rather than name.

    Measured by re-indexing a real Next.js app:

    beforeafter
    symbols extracted482638
    production references with a caller17992247
    production references orphaned589141

    Test files are deliberately unchanged (4581 to 4564). Their calls sit inside anonymous Jest callbacks — it('...', () => { ... }) — which genuinely have no name, and inventing one would be worse than reporting none. The residual 141 are top-level statements like export default React.memo(ColorPicker), which correctly have no enclosing function.

    The wrapper must be called on a bare identifier (memo, forwardRef, styled) or a capitalized namespace (React.memo). That is what keeps items.map(x => f(x)) out: an iterator method returns an array, and treating its binding as a function would attribute everything inside the lambda to that binding instead of to the function actually containing it — trading a missing answer for a wrong one. Both exclusions are pinned by tests. EXTRACTION_VERSION is 8. (#257)

Changed

  • Calls in module-level declarations are attributed, and say so. A great deal of modern TypeScript lives in module-level builder objects — a Zod schema, a tRPC router — where a call sits inside a named binding but not inside a function. Those calls reached neither callers nor impact.

    They are now attributed to the nearest named binding. Since that widens what a reference's from means, the scope travels with it rather than the meaning changing silently: Reference::from_scope distinguishes Function from Module, and the MCP callers tool keeps its exact previous meaning — functions that call this — with the module-level ones in a new module_callers.

    On a real Next.js/tRPC codebase (815 files):

    beforeafter
    production references with a caller2075427524
    production references orphaned77951025
    production orphan rate27%3.6%

    Strictly additive, and measured as such: function-scope attributions came to 20754 — exactly the number that had a caller before, not one changed. The 6770 module-scope attributions are pure addition. A call inside a real function keeps today's answer, including the iterator case #257 guards.

    The nearest binding wins, so z.string() under a PORT: key reports PORT rather than the enclosing schema. Deliberately over-inclusive in the same way RefKind::Value is, which is exactly why the scope is a separate field rather than folded in. Test files are unchanged: their calls sit in anonymous Jest callbacks, which genuinely have no name. JS/TS only; Python's module-level tables and Rust's static initializers are the same shape and each needs its own evidence. EXTRACTION_VERSION is 13. (#268)

  • CommonJS require() is recorded as an import. JavaScript and TypeScript extraction handled the ES import statement only, so a Node codebase written in the older module system got no import-aware resolution at all. All three binding forms are covered: const fs = require('fs'), destructuring, and a rename, which records under its local name as the ES as alias already does. A computed require(name) records nothing, since there is no literal path to key on.

    On the one CommonJS-heavy repository available, imports went from 4 to 93 across 22 files, and relative paths resolve as expected — require('../util/aws') records util/aws.

    Worth stating plainly: that repository has no name collisions among its imported symbols, so this resolved zero ambiguous references there. The extraction is correct and the payoff is prospective — a CommonJS codebase with internal collisions gains what #252 gives every other language. It is not demonstrated on the corpus at hand. EXTRACTION_VERSION is 12. (#269)

  • C and C++ #include directives are now recorded and used to resolve. #252 and #260 gave import-aware resolution to six languages; C and C++ had none, even though an include is a stronger signal than any of them. A package import names a package that a file path is assumed to mirror; an include names the file.

    On a real C++ project (2017 files), 9860 includes recorded across 1614 files. Of the 17884 ambiguous references in files that include something, 4905 have an include pointing at a candidate and 4749 now resolve — the largest single resolution gain of any change in this cycle.

    An include names a path rather than a symbol, so it is recorded with no name and matched by path. Import::brings_whole_file says so explicitly rather than callers testing for an empty string. Dropping the extension is what makes it work: a prototype in a header is not a symbol, the definition in the translation unit is, and both sit at the same path stem, so including render/pipeline.h resolves to render/pipeline.c.

    Narrowing only, as established in #248: a system header matches nothing in the view and leaves the reference ambiguous, and an include fitting several candidates declines unless one is unambiguously nearer. EXTRACTION_VERSION is 11. (#267)

  • Imports now anchor to the file that wrote them. #252 matched an import's module as a free-floating path suffix, which fails exactly where a codebase holds two copies of the same tree — a vendored reference beside your own work, or one package tree per assignment. Every copy matched, so the reference was correctly but uselessly declined.

    Two lexical rules fix it, neither needing a project root. A Python relative import carries its dot count now, so from ..DataTypes import Action resolves by walking up from the referencing file's own package — arithmetic on a path gonzalo already has. And when an absolute import matches several candidates, the one sharing the longest path prefix with the referencing file wins, since a file means the copy in its own tree.

    Measured on four repositories, counting ambiguous references whose file imports the name:

    repocandidatesresolved beforeafter
    CS-5260 (Python)85065
    cs-395-spring-2024291419
    cs-5253-fall-20237010185
    cs-5254-summer-2023679049

    Confirmed through the server rather than only in tests: impact on a symbol defined identically in two parallel trees now reaches 18 callers, all 18 in the importing file's own tree and none in the other.

    Both rules stay narrowing, as #248 established. A tie is left ambiguous rather than broken by a coin flip, candidates sharing no prefix at all are not ranked, and a relative import claiming more levels than the path has falls through to the older rules. EXTRACTION_VERSION is 9, and the store gains an import depth column. (#261)

  • Java and Kotlin imports are now recorded. #252 shipped import-aware resolution for Rust, Python, JavaScript and TypeScript and left out the two most import-dense languages gonzalo supports — every type from another package must be imported by name, so the signal is denser there than anywhere else. Found by indexing three Vanderbilt course repositories, which between them contained thousands of import lines and recorded zero.

    repofilesimports beforeafter
    cs-395-spring-202440102657
    cs-5253-fall-202372806620
    cs-5254-summer-202375205642

    A Java static import records the member it brings into scope rather than the class holding it, and a Kotlin as alias records under its local name. A wildcard records nothing, as with Rust's glob. Kotlin needed one wrinkle: its grammar drops the *, so import a.b.* and import a.b parse identically and the source text is the only way to tell them apart.

    Extraction only — resolution already consumed imports generically. EXTRACTION_VERSION is 7, so each view re-walks once on upgrade. (#260)

  • Imports narrow an otherwise ambiguous reference. Resolution was file-scoped and consulted nothing outside the calling file, so a name defined in several places with none of them local was simply dropped — even when the file's own imports named exactly one of them.

    Each file's imports are now recorded and consulted at the point the ladder would otherwise give up. use crate::model::make prefers the make under model.rs. This is deliberately lexical, not a resolved module path: resolving an import to a file needs the crate root, which means reading the manifest and modelling the workspace, and extraction would then depend on files other than the one being parsed (ADR 0012). An import says which module a name came from, and a module's name almost always appears in its own file's path, which is enough. Three layout conventions are folded in: an entry file (mod.rs, lib.rs, main.rs, __init__.py, index.ts) names its container rather than itself, a src directory is build layout rather than a module, and - and _ compare alike so a crate written gonzalo_cli matches gonzalo-cli on disk.

    It only ever narrows, following #248: when the import fits several candidates the reference stays ambiguous, which is the honest answer, and nothing that resolves today stops resolving.

    Measured by indexing three real repositories — gonzalo itself, caliban, and a Next.js app:

    repoimports recordedambiguous refs whose file imports the namenow resolved
    gonzalo14082711
    caliban58036845
    web-viz-next7822121

    A bare @ in a JS/TS import is a tsconfig path alias rooted at the project (@/lib/utils), not a directory, and left in place it added a segment that could never match — the Next.js app resolved 0 of 21 until it was dropped, and 21 of 21 after. A scoped package (@testing-library/react) leads with @scope, which is a real name and survives.

    Rust, Python, JavaScript and TypeScript. A glob import contributes nothing, since it introduces names the file never spells out. EXTRACTION_VERSION is 6, so each view re-walks once on upgrade, and the store gains an imports table. (#252)

  • Method calls are attributed when the body says what the receiver is. A call through a receiver of unknown type is deliberately not attributed, because guessing was measurably wrong (#223) — but a large share of receivers have a type written down a few lines up. Three purely syntactic shapes are now read: a binding initialized from a constructor-style call, a binding initialized from a struct literal, and a parameter with a written type, including behind a reference. self takes the enclosing impl.

    On gonzalo's own source 1043 of 6732 method references now carry a type, 437 of which name a type the view defines; impact on insert went from 60 symbols reached to 219, and its unattributed method edges fell from 368 to 274. insert is the seed that #248 could not move at all, because it is reached almost entirely through store.insert(..).

    This is syntax, not inference, and the limits are deliberate: a receiver from a plain call, a generic parameter, or a trait object still declines and is still counted, because choosing an impl is the guess #223 removed. Bindings are scoped to the function body they appear in. Rust only for now; every other language records no receiver type and behaves exactly as before. EXTRACTION_VERSION is 5, so each view re-walks once on upgrade. (#251)

  • callers and callees now report how ambiguous the queried name is. Both are raw name matches, so a name defined in several places returns the merged answer for all of them — and a bare list said nothing about that, which is the point where a heuristic result is most easily read as a precise one. Each now answers with a definition_count alongside the list, plus defined_in while there are few enough paths to be a lead rather than noise. A count of 0 means the name is defined nowhere in the view, which is what makes an empty list readable rather than ambiguous between "nothing calls this" and "you asked the wrong question".

    This changes the shape of those two tool results: the list moved under a callers/callees key, so they return an object rather than a bare array. impact already reported the edges it declined; this brings the same honesty to the tools that do no resolution at all. (#249)

Fixed

  • unreferenced no longer reports live callbacks as dead code. A function passed as a value rather than called — and_then(helper), register(handler) — is a path expression, not a call expression, so it recorded no reference at all and the function looked unused. That was the documented main false positive of the one tool whose suggested action is deletion. On gonzalo's own source it was flagging 98 symbols that are genuinely used, among them the test fixtures handed to build_rust and constants passed to functions.

    Such a reference is now recorded with its own kind and deliberately kept out of the call graph: callers, callees and top stay call-only, because passing a function is not calling it. impact counts them in a new value_edges rather than walking them — a callback is a real dependency, so dropping it silently would under-report, but extraction is per-file and cannot tell an identifier naming a function from one naming a local, so traversing them would put false edges back into the walk #207 and #223 cleaned up. Non-zero value_edges means go look.

    The over-inclusion is deliberate and one-directional: a local variable handed to a call is recorded too, which for dead-code detection errs towards not calling something dead. EXTRACTION_VERSION is 4, so each view re-walks once on upgrade. (#250)

Added

  • Qualified symbol identity in the code graph. A call site's path qualifier and a definition's owning type are now both recorded, so Beta::get() is attributed to Beta's get instead of merging with every other get in the view. Two pieces of disambiguating information were sitting in the syntax tree and being discarded: a qualified callee was reduced to its trailing identifier, and the walk tracked only the enclosing function, so a method in an impl block was stored as a bare name with nothing recording what it hangs off. The qualifier only ever narrows — when it names something the view does not own, such as a file module or a dependency, it is ignored and the previous rules apply, so no edge that resolved before stops resolving now. Recorded where a grammar makes a path unambiguously a path (Rust, C++, PHP); Go's pkg.Func() and Java's Foo.bar() cannot be told from a receiver call at this level and stay unqualified rather than record a variable as a type. EXTRACTION_VERSION is 3, so each view re-walks once on upgrade. (#248)

  • Prebuilt macOS Apple Silicon binaries on every tagged release. A v* tag now also builds gonzalo-vX.Y.Z-aarch64-apple-darwin.tar.gz (plus a .sha256) and attaches it to the GitHub Release. The archive holds gonzalo, gonzalo-mcp and gonzalo-parse-worker together, which is the point: installed separately from crates.io those three can land at different versions, and a CLI newer than its parse worker indexes with the old parser and then marks the view current (#212, #228). aarch64-apple-darwin is the only target built; the container image covers Linux. See docs/releasing.md. (#229)

Changed

  • ParseMode and WorkerSource are #[non_exhaustive]. They ship for the first time this release, and the set of ways a worker can be located is already known to be growing, so sealing them now avoids a breaking change later. (#241)

  • Recorded the Rust-native deliverables principle as ADR 0020: gonzalo ships Rust crates, the container image and its own binaries — not client SDKs in other languages — and non-Rust consumers integrate over the daemon plus its published schema. The decision was real but written down nowhere, so the competitor parity evaluation kept ranking Python/TS SDKs as the single most convergent gap in the whole evaluation. All five SDK rows are now marked by design with the citation. (#196)

  • docs/releasing.md no longer claims version bumps are exempt from rate limiting. crates.io enforces a second limit on updates to existing crates, and a 24-crate workspace trips it on every release — v0.5.0 stopped after 23 of 24. The expected 429-and-resume flow, and a check that every crate is live before creating the Release, are now documented. (#227)

  • Documented that the gRPC decode ceiling is per-server, not per-method: raising GONZALO_MAX_BLOB_SIZE also raises the decode limit for record RPCs, which HTTP does not do. Bounded and authenticated, so documented rather than closed. (#194)

Fixed

  • The packaged gonzalo binary is portable. It linked Homebrew's libgit2 at /opt/homebrew/opt/libgit2/…, an absolute path in the Mach-O load command, so on any Mac without Homebrew it died in dyld before executing an instruction — not even --version ran. gonzalo-mcp and gonzalo-parse-worker were always clean, so the archive shipped one broken binary out of three, and it was the one that creates the view the MCP server reads. libgit2 is now vendored and statically linked, matching what the Linux container already did by accident of having no libgit2-dev installed. scripts/package-macos.sh now refuses to package a binary that links anything outside /usr/lib and /System/Library, because which way this went depended on whether the build machine happened to have a system libgit2. (#246)

  • Ticket connectors keep the provider's error message instead of discarding it. SourceError::Backend is documented as carrying the provider's message, but all five connectors reached it through reqwest's error_for_status(), which drops the response body — so an expired token, a missing scope, a malformed JQL query or a rate-limit hint all surfaced as a generic "HTTP status client error". Twenty call sites across Jira, GitHub, GitLab, Asana and Linear now report the status and the provider's own reason, bounded in length. (#240)

  • gonzalo sync expands a leading ~ in its two store roots. Those are positional arguments and were the one place #211's expansion did not reach, so gonzalo sync '~/a' '~/b' from a non-shell context silently operated on two directories that did not exist and reported success. (#238)

  • The parse-worker version note no longer claims correct data is wrong, and no longer repeats. A worker released before --extraction-version cannot report one while still producing current extraction, so that case now reads as an unconfirmed note rather than a warning to go fix your data; a worker that reports a different version still warns plainly. It is also emitted once per process rather than once per index, which under --watch meant once per file save. (#239)

  • Remote reads report failures the way remote writes do. ServerStore's read paths used reqwest's error_for_status(), which discards the response body, so a 403 on a read surfaced as a generic HTTP status line while the same refusal on a write reported the daemon's reason. All four HTTP read paths now carry status and body; 404 still means Ok(None) on get/get_blob. (#195)

  • A leading ~ in a store root is expanded to $HOME instead of being taken literally. GONZALO_ROOT=~/.gonzalo in an MCP client config reaches the process verbatim — there is no shell in that path — so it created a directory literally named ~ in whatever the working directory happened to be, and every query then answered from the wrong, empty store. The form users naturally write, and the one that works when tried in a shell, was exactly the form that silently misbehaved where it mattered. Expansion covers GONZALO_ROOT and every --root argument, which has the same exposure from a systemd unit or container spec, and status now reports the expanded path so it is verifiable. Only a leading ~ is touched: ~otheruser/… is left alone, and a ~ anywhere but the front is an ordinary directory name. (#211)

  • gonzalo index says which parse mode it is using instead of silently degrading. Crash isolation depends on finding gonzalo-parse-worker, and when it could not, indexing quietly parsed in-process — no log line, no warning, no way to tell. The two modes produce byte-identical graphs, so the loss was invisible until a tree-sitter grammar aborted and took down the whole run instead of skipping one file. A parse: line now names the worker and how it was found; the fallback prints a warning listing every location searched; and --require-parse-worker turns a missing worker into a hard error for CI and container builds. Lookup also covers PATH, so symlink and split-install layouts — where a sibling check cannot succeed but the worker is plainly available — find it. (#212)

  • A stale gonzalo-parse-worker no longer produces pre-upgrade extraction that the view records as current. EXTRACTION_VERSION guarded the CLI, but the worker is a separately installed binary and is what actually parses — so a 0.5.0 CLI driving a 0.4.0 worker emitted old-format slices and then stamped the view with the new version, leaving it wrong and marked up to date, which no later re-index would repair. The worker now answers --extraction-version, the indexer asks before parsing, and a view is credited to the version the parse path actually produced. A mismatch warns, rebuilds in full, and self-heals once the worker is upgraded. A worker too old to answer is recorded as unknown rather than assumed to agree. (#228)

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 crate — push, 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)
  • cli — get/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-vector — Embedder + 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-vector — Embedder + 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.
  • GitLab — tier-dependent: free = open/closed + workflow::* scoped labels; Premium = native categorized Status; custom fields GA in 18.0.
  • Asana — no 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.
  • 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 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-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 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.

  • Deletion — hnsw_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/scoring — query 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. (The on-disk case is answered by ADR 0027.)

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

  • Config — GONZALO_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
MinIO✅AGPLrejected — 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.

ADR 0020 · Rust-native deliverables; the daemon is the non-Rust boundary

  • Status: accepted
  • Date: 2026-09-09

Context

gonzalo ships Rust crates. Nothing in this repository ever said so.

ADR 0009 records the Cargo workspace and the unsafe_code = "forbid" lint, which is the memory-safety half of the stack-wide "Rust-native, memory-safe, one Cargo workspace per app" principle (caliban-ai shared principles §5). The other half — that the deliverables themselves are Rust, and that non-Rust consumers are served over a wire protocol rather than by maintained bindings — was never written down anywhere. ADR 0007 gets closest, in a framing line noting that the optional daemon "lets non-Rust tools and remote systems share a store", but it decides transports, not language scope.

An unrecorded decision is indistinguishable from an unmade one, and this one has already cost us. The competitor parity evaluation (#193) derives its Notes column from ADRs. With no ADR to derive from, it ranked Python/TypeScript client SDKs as the single most convergent gap in the entire evaluation — 🔴 in all three matrices and named in every "Reading of the gap" section — for work the project had in fact already decided not to do. The matrices have a by design convention precisely to prevent that confusion; it could not be applied to a decision no document contained. Left alone, every refresh of the evaluation regenerates the same recommendation.

Two ways to record it were considered:

  • Supersede ADR 0009. Rejected. 0009 is about workspace layout and the facade; language scope for deliverables is a different question, and folding it in would churn an accepted ADR whose subject is unchanged — while making the language decision harder to cite on its own.
  • A dedicated ADR. Taken. The decision has its own consequences, its own revisit condition, and needs to be citable from a principles entry and from five evaluation rows.

Decision

We will ship Rust deliverables only: the crates in this workspace, the gonzalod container image, and prebuilt binaries of the workspace's own binary crates (ADR 0009 for the workspace; #229 for the binaries).

We will not publish or maintain client SDKs in other languages — no Python package, no npm package, no FFI layer, no generated-and-vendored bindings kept in this repository.

Non-Rust consumers integrate over the daemon plus its published schema: gRPC or HTTP/JSON against gonzalod, generated from the gonzalo-proto schema artifacts that ADR 0007's single-canonical-schema rule already guarantees cannot drift from the Rust implementation. That boundary is the supported answer, and generating a client from a published schema is a supported thing to do — it is maintaining hand-written per-language SDKs that we decline.

This is a scope decision about what we deliver, not a restriction on anyone else. A third party is free to publish a Python or TypeScript client; it simply is not ours to keep working.

Consequences

  • Positive: one implementation to keep correct. Every capability is exercised by the conformance suite (ADR 0006) rather than re-implemented per language and drifting quietly. The parity matrices can now mark SDK rows by design and stop re-proposing them. unsafe_code = "forbid" stays meaningful, since no FFI surface reintroduces what the lint exists to exclude.
  • Negative: a real adoption cost. A Python user cannot pip install gonzalo; they must run a daemon and generate a client. That is a heavier first step than a library import, and it is the price we are choosing to pay. It also puts weight on the schema artifacts actually being published and consumable — the daemon boundary is only a credible answer if generating a client from it is easy, which is why that work is tracked separately (#198).
  • Revisit if: a concrete adopter is blocked on the daemon boundary rather than merely inconvenienced by it — someone who cannot run a sidecar at all — or if schema-artifact publishing proves insufficient to generate a usable client in practice. Convergent competitor pressure alone is not a reason to revisit; that Mem0, Zep and Letta all ship Python SDKs is a fact about their product shape, not evidence about ours.

ADR 0021 · Replicated deletion with tombstones

Context

This ADR supersedes the local-only deletion decision of ADR 0018. ADR 0018's OCC semantics for delete still hold: an expected revision, Conflict on a mismatch, and atomicity in the same critical section as put. Only its choice to make deletion local-only, which it deferred as a known sharp edge, is replaced here.

Under ADR 0018, delete physically removed a record. Gonzalo replicates in two ways: sync takes the union of two stores' keys and copies one-sided records across, and git pull (ADR 0017) runs a three-way merge. A physical delete leaves no trace, so when sync met a peer that still held the record, "deleted here" and "never existed here" looked the same and sync copied the record back. A deleted record came back on the next sync.

gonzalo#203 asked for a first-class namespace reset: clear a namespace or a collection in one call, with the same meaning on every substrate. A reset built on local-only delete has the same resurrection problem at scale: reset a namespace on a laptop, sync with the team daemon, and every record returns. Gonzalo targets deployments from one local directory up to replicated daemons over shared object storage, and a reset that only works if you never sync doesn't meet that. So deletion had to replicate before reset could be built.

Options weighed:

  • Local-only reset. Simple, and it resurrects on the first sync. Unusable as soon as a namespace is replicated. Rejected.
  • Hybrid: ship local-only reset now, tombstones later. This ships a reset whose meaning changes in a later release, which is worse than waiting for the right meaning. Rejected.
  • Tombstones. Deletion becomes a replicated write. Taken, with the details below.

Decision

We will make deletion a replicated write.

Tombstone record. delete writes a record of the new kind RecordKind::Tombstone at the key's normal path. There is no side index and no new storage layout. The tombstone has an empty inline body, parent set to the deleted revision, and deleted_at set to the deletion time in milliseconds since the Unix epoch. Its revision is { counter: deleted.counter + 1, hash: ContentHash::of(b"gonzalo:tombstone:v1") }. The domain-separated hash means a tombstone's revision can never equal a live edit's revision. If tombstones used the empty-body hash, a live edit to an empty body at the same counter would look "already in sync" with a concurrent delete. The same construction makes two peers that independently delete the same revision produce tombstones with identical revisions (only deleted_at, and possibly the named deleter, differ), which sync treats as already in sync. The shared helpers live in crates/gonzalo-core/src/tombstone.rs.

Two surfaces. Consumer reads (get, list) hide tombstones, so a deleted key looks absent to applications. The replication surface shows them, and sync, pull and collection use only that surface:

  • get_raw and list_raw read tombstones;
  • put_raw(record, expected) writes a record exactly as given;
  • purge physically removes a record if its revision matches, and is now the only physical removal in the system.

All four are required Store methods with no default implementation. A default of get_raw = get or put_raw = put would compile, pass every test that doesn't involve deletion, and resurrect records in production. For the same reason, the daemon client never falls back from a replication call to a consumer call. Against a daemon that predates these routes (HTTP 404 on a raw route, or gRPC Unimplemented) it returns the explicit upgrade error CoreError::Backend("daemon predates replication reads (gonzalo#203); upgrade gonzalod").

Consumer writes. delete of a live record writes the tombstone, or conflicts on a stale expected. delete of a tombstone or an absent key writes nothing and returns Deleted. An absent key has no revision a tombstone could descend from. A consumer put with expected = None over a tombstone is a recreation. The store re-stamps the caller's revision to tombstone.counter + 1 with the tombstone as parent, so the recreated record is newer than the delete instead of losing to it. A consumer put with any Some(expected) over a tombstone returns NotFound, because to a consumer the key is absent.

Replication writes never re-stamp. put_raw follows one rule:

  • nothing stored and no expected: write;
  • nothing stored but Some(expected): NotFound;
  • expected equals the stored revision, tombstone or not: write the record verbatim;
  • anything else: Conflict, carrying the stored record, which may be a tombstone.

This closes a resurrection window. Sync copies a record that exists on only one side after a raw read of the other side. If that copy went through consumer put(record, None), a delete landing on the destination between the raw read and the write would turn the copy into a recreation, re-stamped newer than the delete, and the deleted record would come back. Through put_raw the same race is a Conflict that the next sync pass resolves with the tombstone in view.

Authorship. delete_as(key, expected, author: Option<Identity>) is the required delete method, and delete is a provided method that passes no author. A tombstone carries the deleted record's metadata. When an author is given, it becomes the tombstone's author, so the record of a delete names who deleted it rather than who last edited it. With no author, the tombstone keeps the record's last author. gonzalo delete and gonzalo reset both record gonzalo-cli. Library reset, and the provided delete it uses by default, pass no author, so they keep each record's own last author; reset_as names a deleter, the same way delete_as does.

Who may name a deleter. Over the daemon, a delete may carry an optional claimed deleter: the author field of the HTTP DELETE body, or gRPC DeleteRequest.author_json. ServerStore::delete_as sends its author there. The daemon keeps ADR 0015's promise that authorship can't be forged, and stamps Principal::delete_author(claimed):

  • a non-admin principal is always stamped as itself, whatever it claims;
  • an admin keeps the claim, or is stamped as itself when there is none;
  • a daemon running without auth keeps the claim, or stamps none when there is none, so the tombstone keeps the record's last author.

ADR 0015 defines no separate admin role. The daemon treats a principal as admin when it has "*" in both its read and write lists (Principal::is_admin), as the single GONZALO_TOKEN principal does, and a daemon without auth counts as admin.

Replication writes follow the same author rule. put_raw never re-stamps a record's revision, but the daemon does protect its author. A put_raw from a non-admin principal has meta.author restamped to that principal, exactly like a consumer put. A put_raw from an admin principal, or on a daemon running without auth, keeps the replicated record's author. An admin token is the replication credential, and only it can carry another writer's authorship across stores. Without this rule, any principal with write on a namespace could forge records attributed to someone else by sending them through the raw route.

Raw-write trust equals namespace-write trust. put_raw needs only write on the namespace, and the writer supplies the record's revision, ancestors and deleted_at verbatim, including a tombstone. A namespace writer can therefore write a tombstone with any deleted_at, making it collectable early, or an ancestors list that makes sync fast-forward over a peer. That is no more than write already allows, since a writer can delete or overwrite the record directly. Authorship is the only thing the daemon protects, as above.

A consumer put or put_raw that the store rejects with NotFound, such as a Some(expected) over a tombstone, is returned by the daemon as HTTP 412 or gRPC FailedPrecondition. The client maps that back to NotFound. 404 is not used, because on the raw routes a 404 is how the client recognises a daemon that predates them.

Every substrate makes these decisions through the same pure planner functions in core, so they behave identically by construction. The conformance suite (crates/gonzalo-core/src/conformance.rs) proves it on each substrate. The lock-based stores (fs, git) plan inside their lock. s3 has no lock: its writes are conditional on the ETag it read. When a conditional write loses a race — PreconditionFailed (HTTP 412), ConditionalRequestConflict (HTTP 409), or NoSuchKey (the object vanished to a concurrent purge) — s3 re-reads, re-plans and retries, up to 8 attempts, then returns a backend error. It therefore reaches the same outcome a lock-based store would, instead of guessing from a single re-read.

Ordering by bounded ancestry. Every record carries ancestors, its most recent prior revisions sorted newest first and capped per store. The cap defaults to 32 and is never 0. It is set with --ancestor-cap on gonzalo delete, reset, collect and sync, and with the GONZALO_ANCESTOR_CAP environment variable on gonzalod. When both sides of a sync hold a key with different revisions, sync (crates/gonzalo-core/src/sync.rs) decides:

  • one side's revision is in the other's ancestors: that side is behind, and is overwritten (a fast-forward);
  • neither contains the other, and both are tombstones: the higher (counter, hash) wins on both sides;
  • neither contains the other, and exactly one is a tombstone: a SyncConflict, with neither side written. A delete racing an edit of the same revision can't be resolved without discarding someone's intent, and gonzalo already surfaces unmergeable divergence rather than guessing;
  • neither contains the other, and both are live: the existing merge path.

A chain longer than the cap looks like divergence, so the bounded list fails safe: a conflict or a merge, never a silent overwrite. Records written before this change have no ancestors and take the old merge path unchanged. git pull applies the same kind rules to paths changed on both sides of its real merge base.

Reset. reset tombstones every live record under a prefix that must name a namespace, using ordinary conditional deletes. It is not atomic, because no substrate offers multi-key transactions. It is idempotent: a re-run tombstones what the first run missed and reports keys edited concurrently as conflicts. It needs read and write on the namespace, because it lists and reads the live records before deleting each one, and nothing more: no admin. It stops at the first store error, and a re-run after fixing the cause is safe.

CLI. The CLI works on a local fs store only (--root <DIR>, default .). There are three commands:

  • gonzalo delete --namespace <N> --collection <C> --id <I> [--expected <REVISION_JSON>], where --expected is the revision object from gonzalo get's JSON output (e.g. {"counter":3,"hash":"…"});
  • gonzalo reset --namespace <N> [--collection <C>];
  • gonzalo collect --older-than <DURATION> [--namespace <N> [--collection <C>]], where <DURATION> is a positive integer plus exactly one unit of d, h, m or s.

Each also takes --ancestor-cap <K>, which defaults to 32; a cap of 0 is an error.

Exit codes are 0 on success, 1 on an error, 2 on a usage error, and 3 on a conflict: a stale --expected on delete, or one or more concurrently edited records on reset (re-run to finish). reset prints one conflict: <key> line per conflict on stderr, then X deleted, M conflicts on stdout, and, when M > 0, a final re-run `gonzalo reset` to delete the M conflicted record(s) hint on stderr. A conflict is a normal, recoverable outcome (ADR 0005), so a script can tell "re-read and retry" apart from "something is broken" without parsing output. collect exits 0 even when it reports conflicts. A collect conflict means the key was recreated during collection, and there is nothing to retry.

Collection. collect purges tombstones whose deleted_at is at least an operator-supplied horizon old, conditionally on the tombstone's revision, so a key recreated meanwhile survives. There is no default horizon, and the CLI requires --older-than, which rejects 0. Tombstones without deleted_at are never collected, and a future-dated deleted_at (clock skew) counts as too young. On the daemon, raw reads need read on the namespace and put_raw needs write on it; purge needs admin, and unscoped raw listing needs read on * (ADR 0015).

s3 purge requires an atomic conditional DeleteObject: If-Match evaluated in the same critical section as the removal. RustFS 1.0.0-beta.8 is not atomic. It evaluates If-Match on arrival and deletes whatever object is current when the removal lands: 197/200 violations in a raw purge-versus-create probe, and 39/50 through S3Store. RustFS 1.0.0-rc.6 is atomic: 0/200 and 0/50. RustFS ≥ 1.0.0-rc.6 is the minimum, and docker-compose.rustfs.yml pins it. ADR 0019 is left as written, because accepted ADRs are append-only; its qualification table reflects the original beta.8 run. Other S3 backends need the hardening tracked in gonzalo#286 before they can be qualified.

Version. Required trait methods (get_raw, list_raw, put_raw, purge, delete_as) and a new RecordKind variant break gonzalo-core's API, so this ships as 0.7.0 across the lockstep workspace.

Rejected alternatives for the mechanism:

  • Counter comparison for ordering (higher counter wins). A peer that edits many times offline gets a higher counter than a peer that deleted once, so a stale edit would beat a newer delete. Counters count edits; they don't order them.
  • Version vectors. Exact under every topology, but they need an entry per writer that grows with every replica and principal. That doesn't fit a record format that is also a human-readable file in git.
  • Automatic or background collection. Purging a tombstone too early is data loss that shows up later, on a different machine, when a long-offline peer syncs its live copy back. Only the operator knows how long peers stay offline. An automatic collector would have to guess, and a wrong guess fails silently. Explicit collection keeps that decision with the operator and prints the horizon used. A hub that tracks each peer's last sync could make collection safe automatically later. That's an optimisation, not a correctness requirement.
  • A separate tombstone index. A side index would let list skip reading records, but it is a second structure that must stay consistent with the records on every substrate, and it hides deletes from git history. Keeping the tombstone at the record's own path shows a delete as an ordinary modification.

Consequences

  • Positive: a delete sticks across sync and pull on every substrate, so namespace reset means the same thing locally and replicated. Sync fast-forwards when one side is behind within the ancestor cap: before this, an Opaque kind such as Checkpoint reported a conflict even though nothing had diverged. Concurrent deletes converge without coordination, and delete-versus-edit is surfaced rather than silently decided. The HA soak (crates/gonzalo-soak/) races deletes against edits and recreations across daemon replicas under replica-kill chaos. It checks that acknowledged deletes survive; that no two edits commit on the same base revision, and no edit commits on the same base as a delete that actually wrote a tombstone (StaleBaseCommitted), which catches both a resurrection over a tombstone and a delete that wipes out a committed edit (a delete acknowledged as a no-op over a later tombstone, or whose write can't be confirmed, is skipped rather than flagged); that every run observes at least one delete conflict, seeded deterministically so the check doesn't depend on scheduling; and that the replicas agree on each lifecycle key's deletion state. Every soak replica fronts one shared bucket and no sync runs between them, so the soak tests the daemon's read and write paths, not replication itself, which the sync and pull tests cover.

  • Negative: tombstones take storage until an operator collects them. Consumer list has to read each record to filter tombstones, which on s3 is a GetObject per key and expensive for large namespaces. Deleting a blob-backed record doesn't reclaim its blob, because blobs are content-addressed and may be shared, so purge can't remove them. Mixed versions are unsafe: a pre-0.7 binary that reads a store holding a tombstone fails on that key with a serialization error (loud, no data loss), and a pre-0.7 binary that runs sync against a 0.7 daemon can't see tombstones and copies deleted records back as recreations (silent); the store can't tell such a copy from a genuine recreation, so it can't block it. Every binary that reads a store or runs sync must be upgraded together. A collection horizon shorter than a peer's offline window resurrects records on that peer's next sync. Recreation re-stamps the caller's revision, so a caller that ignores the returned revision and reuses its own gets a conflict on its next conditional write. Blob garbage collection, list performance on s3, and populating Meta.created/Meta.updated are follow-up work.

    git. Don't push a git store while PullReport.conflicts is non-empty. A delete-versus-edit conflict keeps the local side in the merge commit, so a push would publish the local side over the remote's: a live record over its tombstone, or a tombstone over its edit. A crash between writing a tombstone to the working tree and committing it leaves the tombstone uncommitted, and a later pull's forced checkout discards it. put already has the same window; it is noted in gonzalo#283 (atomic git writes). Non-fast-forward pull does not yet use ancestor ordering the way sync does (gonzalo#289), and SyncReport does not signal when sync stops without converging (gonzalo#290). Independent deletes of the same revision converge, but each peer stamps its own deleted_at, so peers may purge at different times. That is harmless, because purge removes only the exact revision it names.

  • Revisit if: a deployment needs collection without an operator-chosen horizon (the trigger for hub-tracked peer sync times); ancestor chains routinely exceed the cap so that fast-forwards degrade into conflicts in practice (the trigger for a larger default or real causality metadata); or s3 list cost on large namespaces becomes a bottleneck (the trigger for a key-level tombstone marker, which is a layout change needing its own decision).

ADR 0022 · Fleet access-control records as a capability layer

Context

Ariel, the fleet's chat bridge (caliban-ai/prospero#67), keeps its identity, role, channel and audit state in gonzalo and stores nothing itself. It needs six record types: a person, a binding from an external account to that person, a role grant, a per-channel configuration, a one-time link token, and an audit entry. None fits an existing RecordKind, and the enum is closed. Principle 5 lets a capability layer register kinds and their merge classes and nothing else in core, with ADR 0010 as the precedent, over the layering of ADR 0008.

Several constraints shaped the design:

  • A name is taken. ADR 0015 already defines Principal: a bearer token with namespace scopes, meaning access to gonzalo. The Ariel draft used the same word for a human with fleet roles.
  • Auth is namespace-granular. ADR 0015 grants read and write per namespace, so the key layout decides what a token can be limited to.
  • AppendOnly is unsafe for JSON bodies. The AppendOnly merge joins two bodies line by line. The first draft proposed it for audit entries, following TicketEvent. Two different JSON entries at one key would merge into a body that no longer decodes, the same hazard gonzalo#204 records for sessions.
  • Deletes now replicate. The first draft argued link tokens must be marked consumed rather than deleted because deletes were local-only and resurrected on sync. ADR 0021 changed that, so the reasoning had to be redone.
  • Link tokens are secrets. The git substrate commits every write, so a raw token in a record body would persist in history.
  • Emails and usernames are tempting lookup keys. Handles are renamed and re-registered, and some platforms return unverified emails.

Options weighed for the audit class were AppendOnly (as drafted) and Opaque; for namespaces, one fleet namespace, a namespace per kind, and fleet plus fleet-audit; for link-token consumption, delete and mark.

Decision

We will add a fleet access-control layer: six RecordKind variants in core with their merge classes, and typed views in a fleet module of gonzalo-domain, re-exported by the facade. The layer is named for fleet access control generally, so prospero's API (prospero#2) and other surfaces can use the same people and roles as Ariel.

Kinds, keys and merge classes.

KindKeyMerge class
Personfleet/people/<person_id>Structured
IdentityBindingfleet/identity-bindings/<authenticator>:<subject>Structured
RoleGrantfleet/role-grants/<person_id>:<scope>Structured
ChannelConfigfleet/channels/<provider>:<channel_id>Structured
LinkTokenfleet/link-tokens/<token_hash>Opaque
AuditEntryfleet-audit/entries/<at_ms>-<nonce>Opaque

The person record is Person, not Principal. It has an opaque id that the minting consumer generates, never an email or a platform id. Composite ids are built only by the views' key helpers, which escape each component so an id stays unambiguous when an OIDC issuer contains :.

Two namespaces. fleet holds the mutable access-control state and fleet-audit holds audit entries. A dashboard or auditor token can then read the trail without reading link tokens or grants, and a writer that only appends audit entries needs no write access to grants.

Merge classes in sync terms. merge_class is consulted only by sync and pull, and only after ADR 0021's ancestor check has ruled out a fast-forward; put is pure OCC for every kind. Field-level merging of a Structured body needs a real common base (git's merge base under pull, or a retained shared parent under sync_with_ancestry, ADR 0016); plain sync has no base, so under it every concurrent edit to one of these configuration kinds surfaces as a conflict rather than merging. Composite fields in the configuration kinds (an externally-tagged actor/origin enum, or a verified-email pair) are stored so they merge as a single unit rather than key by key, so a concurrent change to one never mixes enum variants or one side's field with the other's.

  • The four configuration kinds are Structured: with a common base, separate field edits merge and the same field edited two ways is a conflict. Binding one account to two different people conflicts on person, so a wrong bind is surfaced. Two role changes to one grant conflict on role. A ChannelConfig's repos array merges atomically, so concurrent follows conflict rather than drop one.
  • LinkToken is Opaque. A redemption descends from the revision it read, so sync fast-forwards a peer that still holds the unredeemed token, which stays consumed. Only two independent redemptions of one revision diverge, and those must be surfaced.
  • AuditEntry is Opaque, not AppendOnly. Each entry is written once under a unique key, so divergence means a key collision or tampering and is surfaced instead of merged into an undecodable body. TicketEvent is unchanged.

One record per audit entry. A consumer creates an entry with put(record, None) and never updates it. A Conflict means the nonce collided; the consumer retries with a new nonce. Retention uses the ordinary reset and collect on fleet-audit.

Link tokens.

  • Only a hash is stored. The record holds a domain-separated blake3 hash of the token's 32 random bytes, and the view has no field that can hold the secret. An unsalted fast hash is enough because the input is random, not a password. gonzalo generates no randomness; the caller supplies the bytes.
  • Redemption marks, never deletes. redeem checks the hash, that the token hasn't expired, and that it isn't consumed, then returns the view with consumed set. The consumer writes it with its read revision as expected, so concurrent redemptions give one Committed and one Conflict. Replicated deletes would no longer resurrect a deleted token, but marking is still chosen: a tombstone keeps no record of who redeemed the token, and because redemption checks expiry, a token that reappears after its tombstone is collected is already useless. A token may be deleted only after it expires.

Identifying data. The platform user id or OIDC sub is IdentityBinding.subject and the only lookup key. A username is an optional IdentityBinding.handle snapshot. An IdP-asserted email is an optional IdentityBinding.email with its verified flag. Person has a display_name and an optional operator-set contact email. No person is ever resolved by email or handle. An account joins a person only through a redeemed link token or an explicit operator action, which the binding records.

gonzalo does not enforce fleet roles. These records are data. The consumer evaluates them, including Ariel's two-key rule (a command runs at the lower of the person's role and the channel's ceiling). gonzalod keeps authorizing its own API with ADR 0015's token Principal. ADR 0015's "roles" future work concerns gonzalod's API and is unaffected; making gonzalod authorize by fleet role would need a new ADR.

Relationship to gonzalo#200. Memory scoping by user, agent, session or group is independent. A Person id could later be a user scope value, but neither design depends on the other.

Core change and rollout. Core gains the six variants, their merge_class arms and test assertions, and nothing else. RecordKind serializes by variant name, so an older binary fails to decode these records: a serde error reading from a store, and a rejected PutRequest on the daemon. The kinds ship in 0.7.0 alongside tombstones, which already require upgrading every gonzalod, CLI and embedded consumer together; every binary must upgrade before any writer uses the new kinds.

Consequences

  • Positive: Ariel, and later prospero, get versioned, replicated, conflict-surfacing identity and audit state with a six-variant core change. The two namespaces give the audit trail its own access boundary under existing daemon auth. Raw link tokens never reach storage or git history, a token can't be redeemed twice on one store, and a redeemed token stays redeemed through sync. The views offer no way to resolve a person by handle or email, so a consumer that uses them can't be tricked into account takeover by a reused handle or an unverified email.
  • Negative: Core's closed enum now carries six kinds specific to one layer. Two offline peers redeeming the same token produce a conflict an operator must resolve. Concurrent follows on one channel conflict instead of merging. Under plain sync (no retained common base), every concurrent edit to a configuration record — not just repos — is a conflict an operator or consumer resolves; field-level merging only happens under pull or sync_with_ancestry. gonzalo can't stop a writer with fleet write access from granting itself admin; that trust sits with whoever holds the token, as with any namespace. Emails and handles are personal data, and on the git substrate they survive in history after deletion and collection; the fields are optional, and a deployment that needs erasure should not keep fleet on git.
  • Revisit if: a second consumer needs to extend these kinds in ways a closed core enum can't serve (the trigger for an open or namespaced kind registry); gonzalod needs to authorize its own API by fleet role; offline double redemption turns out to be common in practice; or channel repos conflicts are frequent enough to want a set-merging class.

ADR 0023 · Channel configuration fields, and workspaces instead of repos

  • Status: accepted
  • Date: 2026-09-15
  • Amends: ADR 0022, whose ChannelConfig shape and GrantScope::Repo variant this replaces. ADR 0022 stands otherwise.

Context

ADR 0022 registered ChannelConfig with the shape the Ariel design spec sketched: a provider, a channel id, a role ceiling, a list of followed repositories, and a free-form filters map. Ariel has since settled the record's real contract in its own ADR 0009, and three of gonzalo's fields don't match it:

  • The key is missing the tenant. Ariel's ADR 0006 keys every channel by provider, tenant and channel. A channel id is only unique within its workspace on Slack, and within its team on Teams, so provider:channel collides across tenants — two different channels would share one record.
  • repos: Vec<String> can't express "the whole fleet." Ariel's #ops example follows every workspace, including ones created later. A list has to be edited whenever a workspace is added, and the omission is silent. Prospero has also renamed repositories to workspaces, so the names in this field are workspace names, not owner/name repos.
  • A free-form filters map is unsafe. Ariel's ADR 0007 fixes which event kinds notify and how they are paced. An open map lets a channel opt into kinds that pacing forbids, such as per-token output, which floods the channel.

The same repos-versus-workspaces drift affects GrantScope::Repo, documented as repo:<owner/name>, while ariel scopes grants per workspace.

The fleet kinds are unreleased: the workspace is 0.6.0 and ADR 0022 ships them in 0.7.0. Changing them now costs nothing; changing them after 0.7.0 would need a migration, because a stored record's field names are its wire format.

Options weighed: leave gonzalo as it is and have ariel encode its model into repos and filters (it would have to amend its own ADR 0009, and the tenant collision would remain); or amend gonzalo before 0.7.0. We take the second.

Decision

We will change ChannelConfig to carry exactly the fields ariel ADR 0009 specifies, and rename the repo-scoped grant variant.

Fields.

#![allow(unused)]
fn main() {
pub struct ChannelConfig {
    pub provider: String,       // "discord", "slack", …
    pub tenant: String,         // guild, workspace or team id
    pub channel: String,        // platform channel id
    pub follows: Follows,       // required, no default
    pub notify: NotifyPreset,   // default All
    pub ceiling: FleetRole,     // default Viewer
}

pub enum Follows { Fleet, Workspaces { names: BTreeSet<String> } }  // non-empty
pub enum NotifyPreset { All, Terminal, Failures }
}

Key. fleet/channels/<provider>:<tenant>:<channel>, escaped and joined by the existing key helpers, so the same channel id under two tenants is two records.

follows is stored tagged, as ariel ADR 0009 specifies: {"kind":"fleet"} or {"kind":"workspaces","names":[…]}. Workspaces is a struct variant because an internally tagged enum cannot hold a bare sequence. The name set is non-empty, enforced both by the Follows::workspaces constructor and on deserialization, so no decoded value can break the invariant.

follows merges atomically. It carries ADR 0022's one-element-array wrapper, so the stored field is "follows":[{"kind":"fleet"}]. Two concurrent follow changes conflict rather than splicing two sets together or mixing a kind from one side with names from the other. This keeps the behaviour the old repos array had, where an array compared whole.

notify and ceiling default to All and Viewer when absent, so a record written without them is still valid and grants nothing extra. follows has no default: a channel never silently follows the whole fleet.

gonzalo still doesn't enforce any of this. The presets, the ceiling and the followed set are data. Ariel decides what a channel hears and what a command may do, as ADR 0022 says.

GrantScope::Repo(String) becomes GrantScope::Workspace(String), keyed workspace:<name>, matching prospero's rename and ariel's per-workspace grants. FleetRole also gains a Default of Viewer, the least privileged.

Consequences

  • Positive: A channel record now says what ariel needs it to say, and both of ariel ADR 0009's worked examples are one record. Channels can't collide across tenants. A channel cannot opt into event kinds that pacing forbids, because the presets are a closed set. "Follow the whole fleet" survives a new workspace without an edit. One rename settles the repos-versus-workspaces drift across grants, link tokens and channels while nothing has shipped.
  • Negative: This is a breaking change to kinds ADR 0022 accepted, so any consumer pinning gonzalo from git — ariel does, at f537da7 — must re-pin and update its records. A channel cannot ask for a mix of event kinds outside the three presets, and a per-channel need beyond them requires a new preset here and in ariel. follows stored inside the atomic wrapper is one level deeper than ariel ADR 0009's bare tagged object, so a reader of the raw JSON sees [{"kind":…}].
  • Revisit if: a platform appears whose channels need more than provider, tenant and channel to address; presets prove too coarse for real channels (the trigger for per-workspace presets within one channel); or a consumer other than ariel needs a different channel model, which would argue for moving this kind out of the shared fleet layer.

ADR 0024 · Blob garbage collection, and tombstones pin their blobs

  • Status: accepted
  • Date: 2026-09-19
  • Amends: ADR 0012, whose mark-sweep marked against manifests alone. ADR 0021 named blob garbage collection as follow-up work; this is it.
  • Amended by: ADR 0028, which adds an age rule to the sweep: an unreferenced blob is deleted only once it is old enough.
  • Amended by: ADR 0029, which extends the tombstone pin to the blobs a deleted manifest references.

Context

Blobs are content-addressed and shared: two records with identical content hold one blob, and nothing in a blob records who points at it. Reclaiming one therefore means proving nobody does.

gc_blobs has existed since ADR 0012, but it marked from a caller-supplied list of manifests — the only blob references that existed when code-graph slices were the only blobs. Records whose body is a blob came later, and the CLI's gonzalo gc still passed manifests only. On any store holding a blob-backed record, a sweep deleted that record's content while the record still pointed at it, leaving a dangling reference that no sync could repair. The narrow mark set was a latent data-loss bug, not a design choice.

Deletion adds a second question. A tombstone's own body is empty (ADR 0021), so once a blob-backed record is deleted nothing names its blob. Two answers were open:

  • Free it with the delete. Space comes back immediately. But a delete is replicated, not final: a peer that was offline still holds the live record and can sync it back, and resurrection is exactly what a tombstone's collection horizon is sized to allow for. Recreating the same content — the common case after an accidental delete — would also have to re-upload bytes the store had moments ago. Worst of all, the window where the record exists and its content does not is invisible: reads fail late, at fetch time, with no error at the delete.
  • Keep it until the tombstone goes. The tombstone already encodes "this was deleted, and here is how long we are prepared to be wrong about it". Tying the blob's life to it costs the deleted bytes for exactly the horizon the operator already chose.

A third option — refcounting — was rejected for the reason ADR 0012 rejected it: a drifted count either leaks forever or deletes live content, and neither failure is self-correcting. Marking from the records is.

Decision

A tombstone pins its blob. tombstone_of copies the deleted record's Body::Blob hash into a new Record::deleted_blob field, and GC treats that pin as a live reference. Recreating the key clears it, and collect releases it by removing the tombstone. Reclaiming a deleted record's bytes is therefore three deliberate steps, in order: delete, collect past the horizon, then GC.

The mark set is built from the records, never from a caller. live_blob_hashes unions three sources over the store's raw records:

  1. a record whose body is a Body::Blob — the bytes are its content;
  2. a tombstone's deleted_blob — the pin above;
  3. every slice a graph-manifest names (ADR 0012), referenced from a record's contents rather than its body.

gc_blobs now takes the store itself and does the listing. Marking against any one source alone deletes the other two's content, and a caller holding a partial view of liveness cannot know that. A graph-manifest whose body will not decode is an error, not an empty reference set: silently treating it as referencing nothing would sweep every slice it named.

GC stays explicit and operator-run, for the reason collection is: a blob swept while a peer still holds the record naming it cannot be restored by a later sync.

Consequences

  • Positive: a live record's content can no longer be swept out from under it, which was reachable from gonzalo gc on any store with a blob-backed record. Deletion is fully reversible for as long as the horizon says it is — syncing a record back from a peer, or re-putting the same content, both find the bytes still there. Liveness is derived, so a missed event leaves a blob briefly un-swept rather than wrongly deleted.
  • Negative: deleting no longer reclaims space; an operator who expects it to must now also collect. Deleted-but-uncollected blobs hold space for the whole horizon, which on a store with a long horizon and large bodies is the dominant cost of this decision. A sweep reads every record in the store, so it is O(n) in records rather than in manifests — acceptable for an explicit, occasional operation, and the same shape collect already has.
  • Neutral: Record::deleted_blob is Option, omitted when absent, so existing records and peers that never write it are unaffected. A peer running an older binary replicates tombstones without the pin; its own GC then frees the blob locally, which is the behaviour it had before. sweep_blobs remains available for a caller that has already computed a live set — with the mark set being the dangerous half, it is documented as such.

ADR 0025 · Sibling tombstone markers in the S3 layout

Context

A tombstone lives at the deleted record's object key and differs from a live record only in its body. Hiding tombstones from the consumer list therefore costs a GetObject per key on top of the ListObjectsV2 pages, and reset and collect inherit it. Bounded concurrency (gonzalo#286) capped the latency but not the request count: listing N records costs N round trips and N charges.

ListObjectsV2 returns key, size, ETag, last-modified and storage class. Only the key carries meaning we assign, so making liveness visible to a listing is necessarily a layout change. Two properties of the existing layout make a sibling key safe: segment() percent-escapes ., so no encoded id can contain one and no dotted suffix can collide with a record key; and parse_object_key accepts only three slash-separated parts ending in .json, so any other shape is already skipped rather than misread.

Three layouts were weighed. Moving the tombstone to its own key makes listing entirely free but splits every delete, recreation and purge across two objects, losing the single conditional write that the substrate's concurrency correctness rests on. A per-collection tombstone index is cheaper still to read but serializes every delete in a collection behind one object's compare-and-swap, converting a read cost into a write-throughput ceiling. Both are argued in full in the spec.

Decision

Deleting a record also writes a marker at namespace/collection/id.json.tombstone, whose body is the tombstone's revision. The record object is untouched, so every compare-and-swap remains one conditional write on one object. The marker is a hint about that object, never a second source of truth.

The invariant is one-directional — a tombstone implies a marker; a marker implies nothing — and two rules hold it. First, the marker is written before the tombstone, so a crash between them leaves a marker for a record that is still live rather than a tombstone with no marker. Second, writers never remove a marker; only list does, and only with If-Match on the ETag it saw in its own listing.

The second rule is what makes concurrent removal safe, and the reason is not a crash but a race: if a recreation or a purge removed the marker it found, a concurrent delete — which writes its marker first and its tombstone second — could have that marker deleted out from under it, stranding an unmarked tombstone on a perfectly healthy system. So a recreation leaves its marker stale and a purge leaves its marker orphaned, and the next listing removes them conditionally. Because a tombstone's revision counter always exceeds the record it replaced, no two markers for a key share a body or an ETag, so a marker rewritten since the listing is left alone. A stale marker costs one GetObject to resolve and nothing else, and the layout heals itself.

list makes one pass, sorts record keys from marker keys as it goes, and reads only marked keys. Steady-state cost falls from one GetObject per record, which nothing bounds, to one per tombstone, which collect bounds.

A reader may trust the absence of a marker only where markers have always been maintained, recorded per collection by a flag object at namespace/collection/_tombstone_markers. Until a collection is flagged, list behaves exactly as it does today — reading every key — and, while already reading them, writes the markers it finds missing and then sets the flag. The upgrade installs itself on first use: one listing at the old cost, every later one at the new. Writers maintain markers regardless of the flag, which gates only what a reader may assume.

Consequences

  • Positive: listing a large namespace stops costing a request per record, and reset and collect get the same reduction for free. The tombstone itself does not move, so ADR 0021's single-object compare-and-swap, its recreation semantics and its replication surface are all unchanged. A bucket written by the previous version upgrades without an admin step and without any window in which a deleted record could reappear in a listing.
  • Negative: a delete now touches two objects instead of one, costing an extra PutObject of a few dozen bytes. It is unconditional, so it cannot fail a compare-and-swap, but it is a round trip that can fail on its own. Markers accumulate wherever listings are rare, because only a listing removes them: a workload that deletes and recreates the same keys without ever listing them leaves a stale marker per key, each costing one read whenever a listing finally happens. The first list of each collection after the upgrade still pays the old cost, and a deployment whose collections are never fully enumerated never earns the flag and never gets faster.
  • Neutral: the layout is additive. Markers and the flag are invisible to parse_object_key, so an older reader ignores them entirely and behaves as it does today. Mixed-version deployments remain out of scope for the reason ADR 0021 already gives: a 0.7.0 writer deleting into a flagged collection would write a tombstone with no marker, which is the breakage that ADR's upgrade-together requirement exists to prevent.

ADR 0026 · A grouped facade surface

  • Status: accepted
  • Date: 2026-09-20
  • Amends: ADR 0009, whose single-facade decision stands. What changes is the shape of that facade's public surface, not whether there is one.

Context

The gonzalo facade re-exports 74 names at its top level with default features on, and more as features are enabled: 30 from gonzalo-core and 44 from gonzalo-domain. Many are ordinary English nouns — Actor, State, Provider, Container, Link, Topic, Turn, Session, Priority, Resolution, Person. Two consequences follow.

They collide with a consumer's own types. A program that has its own State or Actor cannot write use gonzalo::*, and even a targeted import reads as a claim on a name the consumer had first.

They hide which layer a name belongs to. State is a ticket state; Actor is a ticket actor; neither says so at the import site, and a reader has to go looking to find out. The flat surface also cannot express two layers using the same word: gonzalo-ticket and gonzalo-graph both define a Page, and a flat facade can only ever re-export one of them.

The alternative is already in the tree twice over. The fleet records are re-exported both flat and as gonzalo::fleet, and one layer down gonzalo-domain is already organised into modules — checkpoint, codec, fleet, memory, session, ticket. The flattening is something the facade does on the way out, not a structure the domain lacks.

Decision

The facade mirrors the module structure the crates already have. It stops flattening gonzalo-domain, and each capability layer becomes a module named after its crate:

ModuleContents
rootthe record and store core — Record, RecordKey, RecordKind, Revision, Body, Meta, Identity, ContentHash, KeyPrefix, Store, BlobStore, BlobEntry, AncestryStore, PutResult, DeleteResult, Conflict, CoreError, Result, MergeClass, MergeOutcome; the operations sync, sync_with_ancestry, merge, collect, reset, reset_as, gc_blobs, gc_blobs_with, SweepPolicy, DEFAULT_MIN_AGE, now_ms and their reports; and the substrates FsStore, GitStore, S3Store, ServerStore
memoryMemoryTier, Topic
sessionSession, Turn
checkpointCheckpoint
codecRecordCodec
ticketthe domain types (Ticket, TicketBody, TicketEvent, State, StateCategory, Actor, ActorRole, Priority, PriorityLevel, Resolution, Provider, Container, Link, LinkKind, LinkTarget, BodyFormat) and the source layer (TicketSource, Capabilities, Cursor, FieldMapping, InMemorySource, Page, SourceError, StateMapping, StateSignal, record_key, scoped_uid) with its connectors (GitHubSource, JiraSource, LinearSource, GitLabSource, AsanaSource)
fleetunchanged — it already exists
graph, vector, knowledgethe feature-gated capability layers, each under its own name

The root holds the record and store core, and nothing else. That is the part every consumer touches whatever else they use, it is what the facade is for, and none of it has ever been ambiguous: nobody is confused about what Record or Store means in a crate called gonzalo. Everything that is a domain noun — a thing the store happens to hold — moves under the module that names its domain.

The grouping is mirrored, not invented. Where a module already exists one layer down, the facade uses it verbatim, including checkpoint and codec, which hold one item each. A module per crate-module is a rule a reader can predict; "the ones we judged to be confusing" is not.

The flat names are removed in the same release that adds the modules (0.8.0). There is no deprecation window. The project is pre-1.0, the surface is versioned, and carrying both spellings would leave the collision the whole change exists to fix — gonzalo::Actor would still occupy the consumer's namespace while gonzalo::ticket::Actor sat beside it.

Because the compiler will say only "unresolved import" and not where the name went, the release ships the mapping the compiler cannot: the changelog entry and the guide carry an old-path → new-path table covering every moved name, and the release notes lead with it. That table is part of the deliverable, not a nicety, and #319 carries it as an acceptance criterion.

Consequences

  • Positive: a consumer's own State, Actor or Page stops colliding with gonzalo's, and use gonzalo::* becomes a reasonable thing to write again. Every import says which layer it came from. Two layers can use the same word — ticket::Page and graph::Page can both exist, which the flat surface made impossible. The facade stops being a hand-maintained list that drifts from the crates it fronts: it mirrors them, so the next domain module appears for free.
  • Negative: every existing consumer's imports break at 0.8.0, in one step, with errors that name the missing symbol but not its new home. That is the cost of skipping the deprecation window, and the old→new table is a mitigation rather than a substitute — a consumer still edits every import by hand. The root/module boundary also has to be defended over time: the pull to promote "just one more" convenient name back to the root is what produced the flat surface in the first place.
  • Neutral: no crate below the facade changes. This is a re-export layout, so gonzalo-domain and the capability crates keep the structure they already have, and a consumer depending on those crates directly sees nothing new.

ADR 0027 · A durable vector index

  • Status: accepted
  • Date: 2026-09-20
  • Source: docs/superpowers/specs/2026-09-20-durable-vector-index-design.md (see that spec's "As built" section for where the shipped code departs from it)
  • Amended by: ADR 0028, which narrows the GC-during-commit window below for live writers.
  • Amends: ADR 0014, whose "Revisit if" named "on-disk or distributed scale" as a future trigger. This ADR is that revisit: it does not change the backend 0014 chose (MemoryVectorIndex exact, HnswVectorIndex approximate) or supersede it — it gives either one a durable home behind the same VectorIndex trait.

Context

Before this change gonzalo-vector's indexes had no persistence and, outside their own unit tests, no callers at all: MemoryVectorIndex (crates/gonzalo-vector/src/index.rs) is a Mutex<HashMap<RecordKey, Vec<f32>>> with no save, load, or Serialize implementation anywhere in the crate, every construction site of an index or a KnowledgeStore in the workspace sat inside a #[cfg(test)] module, and neither gonzalo-cli nor gonzalo-server depended on gonzalo-vector. Losing the process meant losing every vector.

That was tolerable while embeddings could always be regenerated: recall from lost vectors, restart the embedder, re-ingest. #317 removed that option by choosing caller-supplied vectors for MCP recall — the embedder lives on the caller's side of the boundary, gonzalo never runs it, so an index that only exists in memory can never be rebuilt. Persistence stopped being an optimization and became a prerequisite for #317 and for #199's hybrid retrieval.

ADR 0014 had already anticipated this and named it explicitly, under "Revisit if": "we need on-disk or distributed scale". This ADR is that revisit.

Decision

Vectors are bucketed into a fixed number of content-addressed shard blobs, named by one VectorManifest record, and VectorIndex remains the only seam a caller writes or queries against.

Record kind and merge class

RecordKind::VectorManifest is a new record body, VectorManifest { space, dim, shards: u16, entries: BTreeMap<u16, ContentHash> } (crates/gonzalo-core/src/vector_manifest.rs). It lives in gonzalo-core rather than gonzalo-vector because gonzalo gc's mark-set builder (crates/gonzalo-core/src/gc.rs) must be able to parse every manifest kind, and core cannot depend upward on a capability layer (ADR 0008).

Its merge class is MergeClass::Opaque, not Derived. GraphManifest is Derived because a graph manifest can always be rebuilt by re-parsing source — if two writers diverge, gonzalo can just recompute the right answer. A vector manifest cannot be rebuilt this way: gonzalo never sees the embedding model, only the vectors a caller hands it, so treating it as Derived would let a divergence be silently discarded and the corresponding vectors permanently lost. Structured (field-level merge) fails the same way from a different angle: two writers touching the same shard produce two different blob hashes for that field, and a structured merge keeps only one, again losing a writer's vectors without any signal that it happened. Opaque is the only class that turns a divergence into an explicit, surfaced conflict instead of a silent loss.

Sharded blobs

A vector's shard is shard_of(key, shards) (crates/gonzalo-vector/src/shard.rs): blake3 of "<namespace>/<collection>/<id>" via ContentHash, first 4 hex digits parsed as a u16, modulo the shard count. Blake3 is used instead of std::hash::DefaultHasher because the standard library explicitly does not guarantee DefaultHasher's output is stable across releases — a drift there would silently strand every vector in every existing index behind the wrong shard id. The default shard count is 256.

Each shard is one blob, written with a small binary format: 4-byte magic GZVS, a version byte, little-endian lengths, and entries sorted by key before encoding. Sorting means identical shard contents always produce identical bytes, so an unchanged shard content-addresses to the blob already stored and a write that touches other shards costs nothing extra for this one.

The shard count is std::num::NonZeroU16 everywhere in the API, so a caller cannot construct a zero-shard index — dividing by it would panic. The manifest itself still stores a plain u16, because it is deserialized data and a non-zero invariant can't be encoded in the wire format; a stored 0 is instead caught explicitly at open and surfaced as a corrupt-store error rather than reaching shard_of's modulo. Decoding a shard bounds every allocation against the bytes actually remaining in the input, so a corrupt or hostile length field produces an ordinary error instead of an allocator abort.

No new persistence trait

RecordVectorIndex<S> (crates/gonzalo-vector/src/record_index.rs) implements the existing VectorIndex trait rather than introducing a persistence-specific one. VectorIndex is already the seam an external vector backend (#202) is meant to plug into, and a second trait underneath it would have had exactly one real implementation. VectorIndex gained two methods: upsert_many (defaulted to loop over upsert, so no existing implementor breaks) and keys (required, with no default, because there is no way to enumerate an arbitrary index's contents without one — this is a breaking change for any out-of-tree VectorIndex implementor). impl VectorIndex for Arc<T> forwards every method, including upsert_many explicitly rather than inheriting the default — a shared Arc<RecordVectorIndex<S>> handed to multiple callers must still batch a bulk load into one manifest commit, not silently fall back to one commit per vector.

The embedding-space tag

RecordVectorIndex::open(store, key, space, dim) declares an embedding space and dimension. Opening against an existing manifest whose stored space, dim, or shard count differs is an error naming both the stored and the declared value — a swapped embedder is caught once, loudly, at startup, rather than silently producing wrong-but-plausible rankings on every later query.

That check alone is not enough, because it only runs when a manifest already exists. Two handles can each open the same key while it is still missing — there is nothing yet to check either declared value against — and each can declare a different space or shard count. If only one of them ever commits, the other never has cause to look. So the same check also runs on every OCC conflict, against the winning manifest, before anything from it is reloaded into memory: the losing handle must discover the disagreement and refuse to proceed, rather than reload the winner's shards under its own (different) shard count — which would compute different shard ids for the same keys and corrupt the manifest — or write its own vectors into what is now a different declared space.

The limit is stated plainly rather than implied: gonzalo can verify that the space a caller declares at open matches the space recorded in the manifest. It cannot verify that a vector actually came from that model, because with caller-supplied embeddings gonzalo never runs the model or sees anything but the floats. Dimension is the only intrinsic check available, and it is a weak one — all-MiniLM-L6-v2 and bge-small both produce 384-dimensional vectors, so a dimension match proves nothing about which of the two produced a given vector.

The write path

Every upsert, remove, and upsert_many funnels through one commit method that stages dirty shards, writes their blobs, and then puts an updated manifest under OCC, retrying up to 5 times before giving up with an error naming the index key.

The OCC expected revision is the manifest revision this handle's in-memory state was actually built from — a cached last_seen — never a fresh read taken immediately before the put. This is the least obvious rule in the whole design, and it exists because the more natural-looking alternative loses data silently. A fresh read taken right before the write only proves that the store hadn't changed a moment ago; it says nothing about whether the shard bytes being written were computed from state that had already fallen behind. Concretely: if writer A commits first, and writer B then takes a fresh read of the manifest revision as its expected, B's expected matches the store's true current state — because B just read it — even though the shard bytes B is about to write were staged from B's old, pre-A in-memory copy of that shard. The put succeeds, because expected matches, and B's stale shard content overwrites A's committed vectors with no conflict ever reported. Basing expected on last_seen instead closes this: last_seen is the revision that B's in-memory shard content actually reflects, so if the store has moved past it, put reports a real, unavoidable conflict, and B is forced to reload before it can write.

On a conflict, the handle reloads every shard whose blob hash differs between its own last-known manifest and the winner's — not only the shards this commit touches. This is the second non-obvious rule, and it exists for the same reason as the first: reloading only the touched shards looks correct and loses data anyway. Suppose the winner changed a shard this commit never touched (because a different earlier commit changed it). Reloading only the touched shards leaves that other shard stale in memory while last_seen advances to the winner's revision — the handle now believes itself fully synced when it is not. The next write that happens to touch that stale shard stages from the stale in-memory copy, produces a put whose expected matches (because last_seen says it's current), and commits cleanly — silently deleting the winner's vectors in that shard, with nothing to catch it because no conflict occurred. Diffing every shard's blob hash between the old and new manifest, not just the touched set, is what prevents this. The shard(s) this commit itself touches are always included in the reload too, even when their content happens not to have changed, because it costs nothing extra and removes a case from the reasoning.

Two more properties round out the write path. First, deltas are staged on a copy of each dirty shard's current in-memory content and applied to self.inner only after store.put reports Committed — memory never reflects a write that the caller was told failed or that lost a race and is retrying, so a put_blob/put error or an exhausted retry budget leaves the handle serving exactly what the store holds, nothing more. Second, a handle-level commit_lock (a tokio::sync::Mutex<()>) serializes commit calls made on the same handle, because upsert/remove/upsert_many all take &self — without it, two concurrent calls on one handle could interleave their reads of last_seen and each other's staged shard writes.

Both of these rules — basing expected on last_seen, and reloading every differing shard rather than only the touched ones — were found in code review after the original design (a fresh read for expected, reload-only-dirty on conflict) was implemented, reproduced empirically as real data loss, and fixed before merge. They are the parts of this design most likely to look like unnecessary complexity to a future refactor; both are load-bearing.

Shard blobs orphaned by a lost race (the loser's shard write that never made it into a winning manifest) are left unreferenced for gonzalo gc to reclaim, exactly as the code-graph indexer already does for its own orphaned slices. upsert_many validates every vector's dimension before staging anything, then commits the whole batch as a single manifest revision — a 100k-vector bulk load is one commit, not 100k.

Opening is genuinely concurrent

RecordVectorIndex::open fetches shard blobs in batches of 16 concurrent reads via tokio::task::JoinSet, decoding and applying each batch's results to memory sequentially once the batch's reads all land. The store is wrapped in Arc<S> so each spawned read task gets a cheap, independently-owned handle — FsStore is not Clone, and a task needs to own what it reads for its own lifetime. An earlier version of this code chunked reads into batches of 16 but awaited each one in turn inside the chunk — sequential I/O wearing a concurrency constant's name. A test (opening_reads_shard_blobs_concurrently, crates/gonzalo-vector/src/record_index.rs) now observes more than one read in flight at once, so the concurrency claim is checked rather than assumed.

Garbage collection

live_blob_hashes (crates/gonzalo-core/src/gc.rs) gained a VectorManifest arm that marks every blob hash in entries as live. Without it, the first gonzalo gc run after a vector index existed would delete every shard blob while the manifest still pointed at them — an unrecoverable loss, since caller-supplied vectors cannot be regenerated the way a graph manifest can.

Chunk counts across a restart

KnowledgeStore::open(store, index, embedder) (crates/gonzalo-knowledge/src/lib.rs) rebuilds the per-record chunk counts that drive orphan-chunk eviction (#150) from a durable index at construction, scanning it once via keys. Without this, a re-ingest of a shrunk record after a process restart would leave its high-ordinal chunks behind forever, because chunk_counts lived only in memory and reset to empty on every restart. KnowledgeStore::new is unchanged, because a fresh in-memory index correctly starts with empty counts.

Consequences

  • Positive: recall survives the process that built it — the entire point of the ticket. An acceptance test (crates/gonzalo-vector/tests/durability.rs) writes 10,000 vectors, drops the handle, reopens, and queries. Measured reopen time: 258 ms in a release build, 3.43 s in debug, at dimension 16. A real embedding is 384-dimensional — 24× the bytes per vector — so this proves the path works at realistic count and understates its cost at realistic width; it is not the number to quote for opening a real 10,000-chunk index. gonzalo gc's mark set already covers vector shard blobs, the same way it covers a graph manifest's slices, so running GC against a store holding a durable vector index is much safer for live writers than before, but not fully safe (see the commit-window consequence below and ADR 0028 for the residual cases). An external vector backend (#202) still plugs in at the unchanged VectorIndex trait, without anything in this design standing in its way.
  • Negative: one writer per index — OCC detects a second writer and retries; it never merges. A second writer's in-memory view of shards it hasn't touched can be stale until it reopens for reads — writes are now safe against this, per the write-path rules above, but a query can still be served from a not-yet-refreshed shard. The whole index is held in memory once opened — there is no partial or paged load; opening an index means every one of its vectors lives in the process's memory for as long as the handle does. gonzalo-vector now requires a tokio runtime: RecordVectorIndex::open uses JoinSet::spawn, which panics outside a tokio runtime; tokio (feature rt) moved from a dev-dependency to a real one; every gonzalo binary already runs on tokio, so nothing breaks today, but a future non-tokio consumer of gonzalo-vector would break on open. The space tag records what a caller declared, not what is true — it catches configuration drift (a swapped embedder, a restored index, two services disagreeing) but a caller that mislabels its vectors is undetectable, and dimension alone is too weak a check to catch most mislabelings (see "The embedding-space tag" above). keys is a required trait method, so this is a breaking VectorIndex change for any out-of-tree implementor — there was no way to add it as a default that means anything for an arbitrary index. remove of an absent key still performs a real, no-op commit that advances the revision counter with byte-identical content; upsert_many(vec![]) was closed instead (commit now returns early when its delta batch is empty), but remove needs a staged-vs-stored hash comparison inside commit to close the same way, which is separate follow-up work, not this fix. A GC sweep landing during a commit is narrowed for live writers, not closed: commit writes a new shard's blob before it puts the manifest that names it, so a sweep landing in that window used to see the shard as unreferenced and delete it, leaving a manifest that names an absent blob and an index the next open cannot read ("shard N names blob … but it is absent"). ADR 0028 narrows that for live writers: a sweep skips blobs younger than min_age (one hour by default), and commit re-checks, after the manifest lands, any shard blob it newly referenced and re-uploads one that is missing. What remains, first, is a writer that crashes between its manifest commit and that re-check, which can still lose a newly referenced old shard. Because caller-supplied vectors cannot be regenerated, that case is unrecoverable for vectors. A second window needs no crash: a sweep that has already decided to delete an old shard blob deletes it even if a live writer commits a manifest naming it before the delete, because the writer's re-check sees the blob still present. The re-check narrows the exposure to GC's own mark-to-delete interval; it does not remove it, and only durable leases would close both. Running gonzalo gc while vector writes are in flight is therefore much less risky than before, not risk-free. Deleting a vector manifest pins its shards (ADR 0029): the tombstone retains the manifest body, so gc marks the shards live until collect removes it, and gonzalo undelete can restore the index within that horizon. sync does not copy blobs, so an index synced to a peer opens there with a missing-blob error — graph manifests share this same sync gap.

Revisit if

  • Indexes routinely exceed roughly 100k chunks, at which point the whole-index-in-memory model and the full startup rebuild stop being cheap and #202 (an external backend behind the same VectorIndex seam) becomes the answer rather than an option.
  • Multi-writer indexes become a real requirement — OCC-and-retry is not enough on its own, and the shard map would need genuine merge semantics.
  • A trustworthy way to bind a vector to the model that produced it appears, which would let the declared space tag become a verified one.
  • A non-tokio consumer of gonzalo-vector appears, which would force open's concurrency to be built some other way.

ADR 0028 · A grace period for blob garbage collection

  • Status: accepted
  • Date: 2026-09-30
  • Source: docs/superpowers/plans/2026-09-30-blob-gc-grace-period.md
  • Amends: ADR 0024, whose sweep deleted every unreferenced blob regardless of age; this ADR adds an age rule to it. Its marking rules (what counts as live) are unchanged.
  • Amends: ADR 0027, whose consequence "GC is not safe during a commit" is narrowed for live writers, not closed. Two windows remain, both stated under Consequences below: a writer that crashes mid-commit, and a sweep that has already decided to delete a blob a live writer then references.

Neither ADR is superseded; both stay accepted.

Context

Both manifest writers upload blobs before they commit the record that names them: the code-graph indexer (crates/gonzalo-cli/src/lib.rs) writes slice blobs and then the graph manifest, and RecordVectorIndex::commit (crates/gonzalo-vector/src/record_index.rs) writes shard blobs and then the vector manifest. The sweep marked blobs against the records that exist now. So a blob uploaded before the sweep but named by a manifest that commits after it looked unreferenced and was deleted, and the manifest then committed naming a blob that no longer existed.

The damage differs by writer. A missing graph slice is recoverable, because slices regenerate by re-parsing source. A missing vector shard is not: RecordVectorIndex::open fails with "shard N names blob … but it is absent", so one missing shard makes the whole index unopenable, and with caller-supplied embeddings (#317) gonzalo never saw the model and cannot regenerate the vectors.

This is reachable with no operator involved. gonzalo index --gc sweeps after every debounced re-index under --watch (crates/gonzalo-cli/src/watch.rs), so a GC runs on a timer driven by file edits while a writer may be mid-commit. ADR 0024's statement that GC "stays explicit and operator-run" describes the one-shot gonzalo gc command, not that trigger.

There is a second path, through content addressing. put_blob is write-if-absent on every substrate and refreshes no timestamp, so a write that re-references an old blob gets no protection from any age rule. For example, upsert x followed by remove x returns a shard to its earlier content, re-referencing the blob that the first write orphaned. That blob is old, unreferenced, and about to be named again.

Decision

A sweep deletes a blob only when it is unreferenced and at least min_age old, and each writer re-checks the old blobs it newly referenced after its manifest commits. The two halves cover the two paths above: age protects a freshly uploaded blob, and the re-check protects an old blob that a write has just referenced again.

Age rule

  • BlobStore::list_blobs returns Vec<BlobEntry> instead of hashes, each entry carrying the blob's modified time. This is a breaking change to the trait and to the daemon wire (below).
  • min_age defaults to one hour (DEFAULT_MIN_AGE, crates/gonzalo-core/src/gc.rs). gc_blobs(store) and sweep_blobs(blobs, live) keep their signatures and use that default, so the safe behaviour is the default behaviour. gc_blobs_with and sweep_blobs_with take an explicit SweepPolicy. now is a field of the policy rather than a clock read, so the rule is testable without backdating files. gonzalo gc --min-age <duration> exposes it, parsed by the same parse_duration as collect --horizon; gonzalo index --gc, including under --watch, always uses the default.
  • BlobEntry stores modified_unix_ms: i64, not a SystemTime (crates/gonzalo-core/src/store.rs), with from_system_time and age(&self, now) -> Option<Duration>. The type crosses the daemon's HTTP and gRPC surfaces, where milliseconds are the wire form, so one type serves core, client and server instead of three near-identical ones, and age arithmetic stays in integers.
  • Clock disagreements an operator can plausibly hit err toward keeping data. age returns None for a future-dated blob (the GC host's clock is behind the store's) and when the subtraction cannot be represented, and the sweep treats every None as too young. A blob's own timestamp that overflows saturates to i64::MAX, which reads as future and hence too young. Two pathological cases go the other way: an ordinary blob timestamped before the Unix epoch maps to a negative value that reads as very old and collectable (only the saturating extreme, -i64::MAX, overflows in age and is kept), and a now that overflows saturates to i64::MAX, which also reads as very old. Neither is reachable with a sane clock, but they are exceptions, not kept data.
  • A hash listed more than once resolves to its newest timestamp, in a separate pass before any decision is made, so a stale duplicate cannot make a young blob collectable.
  • GcReport has three outcomes, freed, retained and deferred (an unreferenced blob held back for being too young), counted in one loop so the partition is structural rather than derived by subtraction. freed now comes back in ContentHash order, not listing order, because the sweep walks a BTreeMap. The CLI prints deferred from both gonzalo gc and the index --gc path, so an operator can tell why a sweep reclaimed nothing.

Writers re-check after committing

The age rule cannot protect an old blob that was just referenced again, so each writer asks the store whether the blobs it newly referenced are still present, and re-uploads any that are missing.

  • Vector: RecordVectorIndex::verify_shard_blobs, called from commit once the manifest put reports Committed. No staged bytes are retained (a 256-shard batch would hold about 150 MB); the shard is re-encoded on demand from memory, and a re-encode that hashes differently from what the manifest names is a corrupt-state error.
  • Graph: ensure_slices_present (crates/gonzalo-cli/src/lib.rs), called after the manifest commits and before staging consumes the slice values. It checks only added and modified paths, since an unchanged path was referenced by both the old and new manifest and so was never exposed.
  • The check uses BlobStore::has_blob, a defaulted trait method (get_blob(..).is_some()), overridden by the filesystem store with try_exists and by S3 with HeadObject. A HEAD failure that is not "no such key" returns Err, never false: false would make a writer re-upload on every transient fault and hide a broken store. ServerStore keeps the default, so over the daemon the check downloads the blob.

Daemon wire (breaking, unreleased 0.8.0)

HTTP GET /v1/blobs returns [{"hash": …, "modified_unix_ms": …}] instead of ["…"]. gRPC ListBlobsResponse reserves field 1 and adds repeated BlobEntry entries = 2. Reserving rather than reusing the field means an old client decodes an empty list and so an old GC client deletes nothing: it fails safe. docs/api/openapi.json gains a BlobEntry schema.

Why one hour

The window to defend is a writer's upload phase, which is minutes. min_age must exceed that, and it must also absorb clock skew between the GC host and the store. It is both at once, so the skew tolerance is min_age: a GC host running ahead of the store is the direction that loses data, because it makes a fresh blob look old. Git's gc.pruneExpire uses two weeks for the same mechanism, but git has no writer re-check and so needs the margin to carry the whole guarantee. Here the re-check narrows the old-blob case (it does not close it; see Consequences), so the margin need not carry the whole guarantee, and one hour is enough margin for upload and ordinary skew without leaving garbage for days.

Alternatives rejected

  • Sleep through the interval inside the sweep. Every gc would block for minutes and --watch would stall behind it.
  • Two strikes across runs (delete only what two consecutive sweeps found unreferenced). A first gc would free nothing, and GC would gain persisted state to carry the first strike between runs.
  • Durable leases (writers record intent before uploading). This is the only option that survives a writer crash, but it is a new record kind with its own lifecycle and expiry, and deserves its own ADR rather than riding on this one. It is the named revisit trigger below.
  • Refresh the timestamp when a blob is re-put. This still races: GC can list a blob as old, the writer then refreshes it, and GC deletes on a decision it already made. Closing that needs a conditional delete ("delete only if still this old"), which the filesystem substrate cannot do atomically.

Consequences

  • Positive: the race is narrowed for live writers on every substrate: a blob a writer is about to reference is no longer exposed for the whole upload-to-commit interval, only for the much shorter interval described in the second negative consequence below. gonzalo index --gc under --watch is covered with no flag, because the production callers pass DEFAULT_MIN_AGE. deferred tells an operator why a sweep reclaimed nothing, instead of leaving "nothing to free" and "everything too young" indistinguishable.
  • Negative: a writer that crashes between its manifest commit and its re-check can still lose a newly referenced old blob. This design does not close that window. A sweep that lands in it deletes the blob, the process dies before re-uploading, and for a vector shard the index is left unopenable. Durable leases are the only fix, and they are out of scope here.
  • Negative: a live writer can still lose a blob to a sweep that has already decided to delete it. GC computes its mark set, lists blobs, decides an old blob is unreferenced and collectable, and then deletes. If a writer commits a manifest naming that blob after the decision but before the delete, the writer's re-check either sees the blob still present and does nothing, or restores it, and GC's pending delete then removes the blob (or the restoration), leaving a manifest that names a missing blob. No crash is involved. This is the same flaw as the one that rules out freshening on re-put: GC deletes on a decision it already made. The re-check narrows the exposure from the whole upload-to-commit interval to GC's own mark-to-delete interval; it does not remove it. Only durable leases close it entirely. A GC that re-marked immediately before each delete would narrow it further but not close it, since a gap remains between the re-mark and the delete and the filesystem substrate cannot delete conditionally and atomically. Neither is part of this change.
  • Negative: clock skew beyond min_age, in the losing direction (GC host ahead of the store), defeats the age filter. min_age is the skew tolerance; it is not a separate setting, and lowering it for faster reclamation lowers the tolerance with it.
  • Negative: unreferenced blobs now linger for at least min_age before they can be reclaimed, so disk is freed later than before and a gc right after a delete and collect may report deferred rather than freed.
  • Negative: listing blobs now costs a stat per blob. list_blobs went from one read_dir to read_dir plus a stat of every blob, a real per-sweep cost on a large store under --watch --gc.
  • Negative, resolved: ServerStore::has_blob downloaded the blob, so the daemon path paid bandwidth for each newly referenced blob. The HEAD route named here as a follow-up landed in gonzalo#329: HEAD /v1/blobs/{hash} and the HasBlob RPC answer from the daemon's own presence check, and ServerStore overrides the default to use them. The check costs a stat on both sides now. One consequence of answering from presence: HEAD reports content-length: 0, not the blob's size, because a size would need the read the route exists to avoid.
  • Negative: #198's drift check compares paths and methods, not response schemas, so the changed GET /v1/blobs response in docs/api/openapi.json is not guarded by a test. What pins it is the http.rs test that reads the response body as untyped JSON; a future schema change here can drift from the document without CI noticing.
  • Negative: S3's has_blob and the S3 conformance cases are exercised only by CI's HA soak job, which sets GONZALO_S3_TEST_REQUIRED=1. They are skipped locally, so a local green run says nothing about them.
  • Negative: list_blobs returning entries is a breaking change for any out-of-tree BlobStore implementor, and for any client of GET /v1/blobs. A gRPC client built against the old schema sees an empty list, so it fails safe, but an old HTTP client sees objects where it expected strings.

Revisit if

  • A writer's upload phase can exceed an hour, which makes the default min_age too short and leaves the re-check as the only defence.
  • Either remaining window is observed in practice (a writer crash between commit and re-check, or a live writer losing a blob to a sweep that had already decided), which would justify durable leases.
  • GC becomes something a daemon runs continuously rather than a CLI trigger, at which point the ServerStore download cost and the clock-skew question both sharpen.

ADR 0029 · Manifest tombstones retain their body, and undelete restores it

  • Status: accepted
  • Date: 2026-10-03
  • Source: docs/superpowers/specs/2026-10-03-manifest-tombstone-pin-design.md
  • Amends: ADR 0021, whose tombstone discarded the deleted body. For the two manifest kinds the body is now retained, and the tombstone gains a deleted_kind field.
  • Amends: ADR 0024, whose pin covered only the blob a tombstone's deleted_blob named. The pin now also covers every blob a deleted manifest referenced.

Neither ADR is superseded; both stay accepted.

Amended by ADR 0030, which refuses a consumer create over a manifest tombstone and so closes the recreate hazard this ADR originally documented.

Context

ADR 0024 gives deletion three steps: delete writes a tombstone that pins the deleted record's blob, collect removes the tombstone once it is older than the operator's horizon, and only then does gc reclaim the bytes. The pin is what makes an accidental delete recoverable for exactly that horizon.

That promise did not hold for a manifest. tombstone_of (crates/gonzalo-core/src/tombstone.rs) replaced every body with an empty inline one and pinned only a Body::Blob hash. A VectorManifest's body is inline JSON that names its shard blobs out of line, so deleting one did two things:

  • it dropped the shard hashes from the GC mark set, because live_blob_hashes (crates/gonzalo-core/src/gc.rs) marks a manifest's references by the record's kind, and the record's kind had become Tombstone; and
  • it destroyed the shard-to-blob mapping, because the body that held it was discarded.

The next sweep therefore freed every shard. For a GraphManifest that is recoverable, since slices regenerate by re-parsing source. For a vector shard it is permanent: with caller-supplied embeddings (#317) gonzalo never saw the model and cannot regenerate the vectors. ADR 0027 carried this as a known consequence.

It is reachable by an operator. gonzalo delete on a manifest key does it, and so does reset, which tombstones every key in a namespace (crates/gonzalo-core/src/reset.rs), manifests included. Nothing else recovers the lost body: AncestryStore retains committed bodies by revision hash, but it is never constructed outside its own tests, so no production path can recover a discarded one.

Why pinning hashes alone was rejected

The first idea was to generalise deleted_blob into a set of hashes. That buys almost nothing. The mapping is gone, so the preserved bytes are unnameable: nothing can say which blob was which shard, or that they belonged to one index. And a shard genuinely shared with another index is already marked live by that index's manifest, so the pin adds nothing there either. Recovery needs the mapping, not just the bytes, so the body has to be kept.

Two other options were weighed and rejected. Documenting the exception (deleting a manifest is immediate and final) costs no code, but it makes the promise true by narrowing it, and a recoverable delete of a vector index is worth having. Refusing delete for manifest kinds is cheap, but it makes reset either fail partway through a namespace or silently skip records, and it removes a capability rather than fixing a defect.

Decision

A manifest tombstone retains the deleted body and records which kind it was; GC marks through that body; and a new undelete restores the manifest from it.

  • Retention. Record gains deleted_kind: Option<RecordKind>, beside deleted_at and deleted_blob, with the same #[serde(default, skip_serializing_if = "Option::is_none")] attributes, so records already on disk read unchanged. tombstone_of keeps current.body and sets deleted_kind for GraphManifest and VectorManifest only. Every other kind is exactly as before: empty body, deleted_blob pinning a Body::Blob hash, deleted_kind: None.
  • Marking. live_blob_hashes gains an arm for a Tombstone whose deleted_kind is a manifest kind, parsing the retained body with that kind's parser. deleted_kind is why this is a field rather than a guess: trying both parsers on every tombstone body would be ambiguous. A retained body that fails to parse is an error, never a reason to sweep, matching the existing manifest arm.
  • collect is unchanged. Removing the tombstone unmarks the hashes and the next sweep frees the shards. The horizon still ends; ADR 0024's three steps now work for manifests.
  • undelete. gonzalo_core::undelete, the gonzalo undelete subcommand and the facade re-export restore the manifest. It refuses, writing nothing, when there is no tombstone, the record is live, deleted_kind is None, a blob the body names is missing (it lists the hashes), or the key was re-created in the meantime. Otherwise it writes the retained body back under deleted_kind, one counter past the tombstone, with the tombstone as parent and meta.created preserved. It restores body, kind and meta; links are not restored because a tombstone never kept them.
  • Manifest kinds only. Every other kind's tombstone still has no body, so there is nothing to restore it from.

Two properties of tombstone_of were checked because they are easy to break. Two peers deleting the same revision still produce byte-identical tombstones: a tombstone's revision hash is a constant that never depended on its body, and the retained body is a deterministic clone. And MergeClass::Opaque still holds for RecordKind::Tombstone, with sync reconciling tombstones before any body merge, so retaining a body introduces no merge path that did not exist.

Why put_raw and not a new planner arm

undelete rides put_raw, the replication write. Be plain about that: it is the replication planner, and "raw" here means a record that already happened, which a restoration is. plan_put_raw already writes a non-tombstone record over a tombstone when expected matches the tombstone's revision, and it never re-stamps anything. That is exactly an undelete, and it is what preserves meta.created. A third planner arm would add a decision path to the planner every substrate routes through, for no behaviour the existing one lacks. This needs no new Store method, no substrate change, no wire change (records cross gRPC as bytes record_json) and no daemon route (it uses get_raw, put_raw and has_blob, which already have routes). Authorization is write on the namespace, as for any raw write.

Because undelete passes the tombstone's revision as expected, it does not trip plan_put_raw's guard that makes a replication create over a tombstone a Conflict. A restored record's parent is the tombstone, so peers order it after the delete rather than racing it.

Why put keeps resetting created

plan_put treats a tombstone as absent, so a create recreates past it and resets meta.created: a recreation starts a new life at that key. Preserving created there would be wrong, because put cannot distinguish restoring a deleted record from reusing a recycled key, and it would make an identical call mean different things depending on whether collect had run. Only an explicit undelete can say "this is the same record returning".

Consequences

  • Positive: ADR 0024's promise now holds for manifests. Deleting a vector index no longer frees its shards on the next sweep, and a deleted index is recoverable for the whole horizon.
  • Positive: the fix adds no wire surface, trait method or planner arm. Old records read unchanged, and a tombstone written before this one marks nothing, as it did not before.
  • Negative, resolved by ADR 0030 and gonzalo#340: a RecordVectorIndex opened over a manifest tombstone used to open successfully. RecordVectorIndex::open (crates/gonzalo-vector/src/record_index.rs) read with store.get, which hides tombstones, so it built a fresh empty manifest and failed only at its first commit. ADR 0030 made that commit refuse, so the restore window always survived the attempt; gonzalo#340 then moved the refusal to open itself, so the failing call is the one that was wrong. open now reads raw and refuses on the same condition plan_put does.
  • Negative: a divergent delete can drop a pin early. When two peers delete different revisions of the same manifest key, sync's diverged-tombstone merge calls tombstone_winner (crates/gonzalo-core/src/tombstone.rs), which keeps the tombstone with the higher (counter, hash) and discards the other. Shards unique to the losing revision lose their pin before the horizon. It degrades gracefully: the surviving tombstone is a complete, self-consistent manifest, so undelete restores a coherent index, and the higher counter means the newer manifest is the one that survives. Selection does not look at the body, so when the counters differ every peer converges on the same tombstone. When the counters are equal they do not. tombstone_hash() is a constant (ContentHash::of(b"gonzalo:tombstone:v1")), so every tombstone's revision hash is identical and two tombstones at the same counter have equal revisions. tombstone_winner compares (counter, hash) with >=, so on a tie each peer keeps its own side, and both sync (Relation::InSync) and git pull then treat the pair as already agreeing. The peers end up holding different retained bodies at the same revision, permanently and silently. It is reachable whenever two peers' live manifests diverged at the same counter (same counter, different body hash) and both were then deleted. Revision-level convergence holds; body-level convergence does not, and each peer's undelete restores its own manifest.
  • Negative: an older binary's gc frees the shards the pin protects. Record has no deny_unknown_fields, so a binary that predates deleted_kind reads a manifest tombstone without error, but its live_blob_hashes does not mark through the retained body, and re-serialising the record drops the field. Its gc therefore frees exactly the shards the tombstone exists to pin, and a vector index's shards cannot be regenerated. Compatibility holds one way only: old records read unchanged under the new binary, but a new record is not safe under the old one. The realistic exposure is one store root run by two binary versions; sync copies records and not blobs, so peers with separate blob stores are not affected. Upgrade every binary that runs gc against a store before relying on the pin.
  • Negative: a manifest tombstone is no longer small. It carries the manifest's JSON, a few hundred bytes for a 256-shard index, until collect removes it. That departs from "tombstones are tiny and uniform".
  • Negative: only manifest kinds can be undeleted, and past collect a deleted index is gone. The second is ADR 0024's design rather than a gap; the first is a deliberate asymmetry, since retaining every kind's body would bloat every tombstone.
  • Negative: #325's residual sweep windows still apply. A sweep that decided to delete a shard before the tombstone existed will delete it even though the tombstone now pins it. #328 tracks closing them.
  • Negative: undelete restores the record, not the world. If another index referenced the same shards and was itself deleted and collected, those blobs are gone and undelete refuses, naming them.
  • Negative: undelete's PutResult::Conflict arm, taken when the key changes between its read and its write, is a race guard that no single-threaded test reaches.
  • Negative: #198's drift check compares paths and methods, not schemas, so the deleted_kind addition to the Record schema in docs/api/openapi.json is not guarded by a test.

Revisit if

  • A kind other than a manifest needs to be undeletable, which would mean deciding when a body is worth retaining rather than hard-coding two kinds.
  • Manifest tombstones grow costly: a very large shard count, or very many deleted indexes inside one horizon.
  • put ever needs to express succession explicitly, which reopens the question of how a consumer write may name a tombstone (decided in ADR 0030 by refusing the create), and would be the principled alternative to the refusal in ADR 0030.
  • A divergent delete dropping a pin early, or an older binary's gc freeing pinned shards, is observed in practice.

ADR 0030 · A create over a manifest tombstone is refused, and gonzalo purge discards one deliberately

  • Status: accepted

  • Date: 2026-10-03

  • Source: docs/superpowers/specs/2026-10-03-recreate-over-manifest-tombstone-design.md

  • Amends: ADR 0029, whose negative consequence named this hazard and left it documented rather than closed. A consumer create over a manifest tombstone is now refused.

  • Amended by ADR 0031, which closes two of the three known limitations below: the daemon's unguarded purge route (gonzalo#341) and the namespace writer's raw write (gonzalo#342).

ADR 0029 is not superseded and stays accepted; its retention, marking and undelete decisions stand unchanged.

Context

ADR 0029 makes a deleted manifest recoverable: the tombstone keeps the body, gc marks through it, and undelete restores it until collect removes the tombstone. That window is only as strong as the weakest write that can reach the key.

plan_put (crates/gonzalo-core/src/tombstone.rs) treats a tombstone as absent. That is correct under ADR 0021, because consumers never see tombstones, so from a consumer's side a deleted key and a never-written key are the same thing, and a create there must succeed. But a create over a manifest tombstone overwrites it: the retained body is cleared, meta.created is re-stamped, and the blobs the body named lose their pin. For a vector index the shards cannot be regenerated, so this is permanent loss, reached by an ordinary write.

Two paths reach it, and they reach it for the same reason: each derives expected from a consumer get, which hides the tombstone, so each concludes "nothing is here" and creates.

  • RecordVectorIndex::open_with_shards (crates/gonzalo-vector/src/record_index.rs:165) reads the key with store.get, sees no record, and builds a fresh empty manifest. Its first commit is a Record::create with expected: None.
  • The graph indexer in gonzalo-cli does the same for a GraphManifest key after gonzalo delete. The stakes are lower, since slices regenerate from source, but the restore window is gone all the same.

The vector commit path had already anticipated recreation over a tombstone, for revision bookkeeping (crates/gonzalo-vector/src/record_index.rs:439: the store may re-stamp the revision when a record is recreated over a tombstone). It did not treat it as a data hazard.

The most likely operator action after an accidental delete is to restart the application and reopen the index, and that is exactly the action that made undelete impossible.

Decision

plan_put refuses a consumer create over a manifest tombstone, and gonzalo purge is the deliberate way to discard one.

  • The guard. When expected is None and the stored record is a tombstone whose deleted_kind is GraphManifest or VectorManifest, plan_put returns PutPlan::Rejected(MANIFEST_TOMBSTONE_RECREATE_REJECTED). The constant is in crates/gonzalo-core/src/tombstone.rs and re-exported from the crate root. Its message points at the two exits: gonzalo undelete to restore, gonzalo purge to discard.
  • The trigger is an explicit kind match, not deleted_kind.is_some(). deleted_kind is serde(default), and plan_put_raw stores replicated records verbatim, so a tombstone arriving from a peer can carry any deleted_kind it likes. A foreign tombstone with a non-manifest value has no pin and no restore window; refusing a create over it would protect nothing, and undelete would refuse it too, stranding the key with purge as the only exit. Matching the two manifest kinds keeps the refusal exactly as wide as the window it guards.
  • Surface. PutPlan::Rejected already carries a &'static str and every substrate already maps it to CoreError::Invalid, which the daemon answers as HTTP 400 / gRPC InvalidArgument, not 500. No new plan variant, Store method, wire field or substrate code is needed. The PutPlan::Rejected rustdoc previously said Backend; that was stale and now says Invalid.
  • Replication and undelete are untouched. plan_put_raw is unchanged, so a replicated create over a tombstone still Conflicts and undelete still writes through it. A conformance case holds all five substrates to the refusal (the S3 arm runs only in CI's HA soak job, since it skips without GONZALO_S3_TEST_ENDPOINT/BUCKET; the ordinary gate covers four substrates) and asserts the tombstone's revision, retained body and deleted_kind all survive it.
  • gonzalo purge --namespace --collection --id. It removes one tombstone and discards its restore window. It reads raw and refuses anything that is not a tombstone. An absent key is an error (exit 1), unlike gonzalo delete, because an operator who typed a key to discard expects it to exist. There is no prompt and no --yes: the command's only effect is the one its name says. It prints the key and the removed revision. Store::purge and plan_purge are unchanged.

Why the chokepoint, not open

The hazard is a property of the consumer write path, not of one function. Guarding RecordVectorIndex::open alone would fix the likeliest trigger and leave the graph indexer, and every future writer that derives expected from a consumer get, exposed. Putting the check in plan_put makes it hold once, for every substrate and every consumer caller (plan_put_raw is deliberately untouched, which is what keeps undelete and replication working), and it is the only place that sees the stored tombstone and the incoming write together.

Why both manifest kinds

ADR 0029 retains both bodies for the same reason, so the guard follows it. An asymmetric rule, guarding vector manifests but not graph manifests, would need justifying forever, and it would leave a graph manifest's window silently destroyable by the first gonzalo index.

Why the purge check is in the CLI, not plan_purge

plan_purge is kind-blind: it removes whatever record matches the revision. collect depends on that contract, and Store::purge is documented as collection's mechanism, so tightening it would change a surface other code relies on. Purging a live record would leave no tombstone behind, and a peer that had not synced since would resurrect it (ADR 0021). The check therefore sits at the operator surface, where a person can mistype a key, and the CLI refuses a live record and tells them to delete it instead.

Consequences

  • Positive: the restore window ADR 0029 promises now holds against ordinary writes, not only against an operator who knows to run undelete first. Reopening a deleted index no longer destroys it.
  • Positive: no new wire surface, plan variant or substrate change. Operators gain a precise single-key purge they did not have; before, discarding one tombstone meant collect with a horizon that swept others too.
  • Negative: gonzalo reset followed by gonzalo index now fails until the operator runs gonzalo purge or gonzalo undelete on each manifest key. That is friction by design: reset tombstones manifests, and rebuilding over them used to discard the window silently.
  • Negative: a put that used to succeed now errors. Any client that creates at a manifest key over its tombstone gets a 400 where it used to get a revision. This is a behaviour change, and it is the point of the guard.
  • Negative: the refusal message is a &'static str and cannot name the key. The caller already knows which key it wrote.
  • Negative, resolved in gonzalo#340: a RecordVectorIndex opened over a tombstone used to open successfully and fail only at its first commit. open read through store.get, which hides tombstones, so it could not tell. Nothing was lost and the failure was loud, but the error surfaced from inside a commit rather than from the call that was wrong. open now reads raw and refuses on this ADR's own condition — the stored tombstone's deleted_kind naming a manifest kind — so the two surfaces agree by construction, and the refusal can name the key, which the static message below cannot. No force flag was added: a caller starting over runs gonzalo purge first, keeping the discard of a restore window one explicit operator action.
  • Negative, narrowed in gonzalo#340: a refused upsert costs one orphaned shard blob until the next sweep. Refusing at open means a reopen stages nothing at all, so this now only applies to a commit refused on a handle that was already open — a delete that lands between open and the commit. RecordVectorIndex stages the shard blob before the manifest write that is then rejected. It is the same class of orphan a lost OCC race already produces (crates/gonzalo-vector/src/record_index.rs:368), reclaimed by gc, but it is a new way to reach it.
  • Negative, resolved by ADR 0031: the daemon exposed Store::purge to an admin principal with no tombstone check (crates/gonzalo-server/src/service.rs:162, crates/gonzalo-server/src/http.rs:430, crates/gonzalo-server/src/grpc.rs:285). An admin can therefore physically remove a live record over either transport, which is the same ADR 0021 resurrection hazard that the CLI check closes. The route arrived with the replication surface and predates this decision; closing it was out of scope here and was tracked in gonzalo#341. Service::purge now refuses to remove a live record, narrowly enough to leave collection's OCC-race Conflict intact.
  • Negative, resolved by ADR 0031: a principal with plain Write on a namespace could destroy the restore window through the raw route. PUT /v1/raw/records/{ns}/{col}/{id} authorizes Access::Write, not admin (crates/gonzalo-server/src/http.rs:382-385; compare purge_record at :420, which requires admin), and GET /v1/raw/... needs only Access::Read. A tombstone's revision is {live_counter + 1, tombstone_hash()}, and tombstone_hash() (crates/gonzalo-core/src/tombstone.rs:21) is a constant, so the revision is predictable even without reading it. Such a principal can put_raw a fresh empty manifest naming that revision, and the retained body, the pin and undelete all go: no race and no admin token. The fix belongs at the route, not in plan_put_raw, whose matching-revision arm is what undelete rides. Tracked in gonzalo#342, a sibling of #341 that needs lower privilege. The route now requires admin, matching POST /v1/purge/....

Open questions from the ticket

The ticket (#333) left three questions open. The guard went in plan_put rather than open, for the reason above. Both manifest kinds are covered. gonzalo index needs no separate treatment, because the same plan_put arm covers it.

Revisit if

  • A non-manifest kind starts retaining a body. A restore window would then exist for a kind the explicit match does not name, and the guard's trigger would need widening deliberately rather than by deleted_kind.is_some().
  • Operators find purge-then-create too sharp a way to discard a deleted index.
  • PutPlan::Rejected gains a dynamic message, which would let the refusal name the key.
  • The daemon's purge route gained its tombstone check in ADR 0031, and RecordVectorIndex::open learned to see tombstones in gonzalo#340. Both of this record's daemon-side and library-side limitations are now closed.

ADR 0031 · The daemon's physical-removal surfaces require admin, and refuse to remove a live record

  • Status: accepted
  • Date: 2026-10-06
  • Amends: ADR 0030, two of whose three known limitations this closes (gonzalo#341 and gonzalo#342). ADR 0030 is not superseded and stays accepted; its plan_put guard and gonzalo purge escape hatch stand unchanged.
  • Amends: ADR 0015, which authorized the raw replication write with write on the namespace. It now requires admin, so the authorization table in that record's scope has one fewer non-admin route.

Context

ADR 0030 closed the accidental path to destroying a deleted manifest's restore window: plan_put refuses a consumer create over a tombstone carrying a manifest deleted_kind. It also recorded, and deliberately left open, two ways to reach the same damage over the daemon. Both were found in the whole-branch review of gonzalo#333 and are now confirmed by test rather than by argument — each reproduced as a failing assertion before this change.

A namespace writer could destroy the restore window (gonzalo#342). PUT /v1/raw/records/{ns}/{col}/{id} authorized Access::Write, while the comparable POST /v1/purge/... required admin. The raw route reaches plan_put_raw, which writes verbatim and so bypasses ADR 0030's guard — by design, because that same matching-revision arm is what undelete rides. A tombstone's revision is {live_counter + 1, tombstone_hash()} and tombstone_hash() is a constant, so the revision is predictable without even reading it. A principal holding plain Write could therefore aim a fresh empty manifest at the tombstone and take the retained body, the shard pin and any chance of undelete with it. No race, no admin token.

The daemon could physically remove a live record (gonzalo#341). plan_purge is kind-blind: it removes whatever matches the expected revision, live or tombstone. gonzalo#333 guarded the CLI's gonzalo purge against that, because purging a live record leaves no tombstone, and a peer that has not synced since would resurrect it — the failure ADR 0021 designed tombstones to prevent. The daemon had no equivalent check, so ADR 0030's claim that "the check belongs at the operator surface" held for only one of the system's two operator surfaces.

Decision

1. The raw replication write requires admin, on both transports, matching POST /v1/purge/... and the Purge RPC. The authorization check also now runs before the request body is inspected, so an unauthorized caller cannot learn whether its record agreed with the path, and on gRPC cannot reach serde at all (the ordering gonzalo#146 established for purge).

This is the credential replication already uses: ADR 0015 named an admin token the replication credential, and daemon-to-daemon and operator replication run with one. The non-admin path existed only to be hardened — a non-admin's raw write was restamped with the caller's identity so authorship could not be forged. That restamping is now unreachable and is deleted; authorship stays unforgeable by construction, because no non-admin reaches the route.

2. Service::purge refuses to remove a live record, so the daemon and the CLI agree. The refusal is CoreError::Invalid, which both transports already map to the caller's error rather than an outage (400 / InvalidArgument, gonzalo#299), and the message directs the operator to gonzalo delete while warning that purging the resulting tombstone would discard the restore window.

The refusal is deliberately narrow: only a revision that matches a live record. That is the only call that would actually remove one. Two neighbouring cases must keep their existing answers, because tombstone collection depends on both, and a first, broader attempt at this guard broke the second:

  • A mismatched revision over a live record is collect losing an OCC race to a recreate between listing and purging. plan_purge answers Conflict and removes nothing, which collect records in CollectReport::conflicts. Refusing there instead would turn a routine race into a hard failure of the whole collection run — caught by conformance::purge_conflicts_after_recreation across all five substrates.
  • A key that is already gone still reports Deleted, so re-running collection after a partial run stays safe.

Alternatives rejected

  • A kind-aware refusal on the raw route, leaving ordinary replication to non-admin tokens. This was gonzalo#342's narrower option. It breaks convergence: a peer that legitimately purged a tombstone and created a manifest at that key could no longer replicate that state, because the receiving daemon would refuse the write. Replication must be able to carry any state a peer legitimately reached.
  • A guard inside plan_put_raw. Its matching-revision arm is exactly what undelete rides, so refusing there would break the capability the guard exists to protect.
  • A guard inside plan_purge. That is core, shared by collect and every substrate, and it is where the kind-blindness is load-bearing: collect needs Conflict, not an error, and ADR 0030 deliberately kept kind-awareness out of core. The check belongs at the operator surface, which is what Service is for the daemon.

Consequences

  • Positive: the two documented ways to destroy a manifest's restore window over the daemon are closed, and both are pinned by tests that fail without the change — including end to end against a real daemon over HTTP.
  • Positive: the daemon's two physical-removal-equivalent routes now agree with each other and with the CLI. "Physical removal needs admin" is true of the whole surface rather than half of it.
  • Positive: authorization precedes body handling on both raw transports, so the gonzalo#146 ordering now covers the raw write too.
  • Negative, BREAKING: a replication peer configured with a non-admin token stops working. Its put_raw begins returning 403 / PermissionDenied where it previously succeeded. ADR 0015 already described an admin token as the replication credential, so a correctly configured peer is unaffected, but this is an API-visible authorization change and any peer relying on a scoped token must be re-credentialed.
  • Negative, BREAKING: an admin purge of a live record at its current revision now fails with 400 / InvalidArgument where it previously succeeded. Tombstone collection is unaffected; a caller deliberately removing a live record must use gonzalo delete and then purge the tombstone, which is the sequence that keeps peers convergent.
  • Negative: Service::purge costs one extra get_raw per call. Collection over a daemon therefore pays two reads per eligible tombstone rather than one — it already reads each key to classify it. That is proportional, not asymptotic, and collection is background maintenance, but it is a real cost on a large store.
  • Negative (known limitation), since closed: RecordVectorIndex::open read through store.get, so opening a deleted index succeeded and failed later at the first commit. Unchanged by this decision, and closed separately in gonzalo#340.

Revisit if

  • A replication topology appears that genuinely needs a non-admin writer — a partially-trusted peer allowed to replicate one namespace and nothing else. The answer would be a distinct replication capability rather than reverting to Access::Write, since plain namespace write is what made the restore window reachable.
  • Some peer-repair flow turns out to need the physical removal of a live record. Then the route should be reopened explicitly, with the resurrection hazard stated, rather than left reachable by omission as it was here.
  • The extra get_raw per purge shows up in collection timings on a large store. The revision-hash shortcut — a tombstone's hash is the constant tombstone_hash(), so a non-matching hash could be refused without a read — would avoid it, at the cost of a proxy check that a record whose body happens to equal the tombstone domain would defeat.