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
| project | role |
|---|---|
| caliban | the agent. Gonzalo started as a shareable home for its local-first state. |
| prospero | runs the caliban agent fleet |
| ariel | the 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. |
| gonzalo | persistence: 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 / crate | what it is | guide |
|---|---|---|
gonzalo (crate gonzalo-cli) | admin CLI: inspect and import records, sync stores, index code graphs, sync tickets | The CLI |
gonzalod (crate gonzalo-server) | the daemon: a store served over HTTP/JSON and gRPC, with namespace-scoped auth | Running gonzalod |
gonzalo-mcp | MCP server that answers code-graph and record queries for agents | The MCP server |
gonzalo (library) | facade over the core, substrates and layers, selected by Cargo feature | Storage backends |
Where to go next
- New to the code graph? Start with The MCP server. It covers install, indexing and registration end to end.
- Deploying the daemon: Running gonzalod.
- Talking to a daemon from something other than Rust: Generating a client.
- Picking a substrate, or embedding gonzalo in a Rust program: Storage backends.
- Deleting records so the delete survives a sync: Deletion, reset & collection.
- Why things are the way they are: Guiding Principles & Invariants and the architecture decisions.
- Crate docs: the API reference.
Guiding Principles & Invariants
Gonzalo's design philosophy is otherwise recoverable only by reading the full ADR log and the founding design spec. This page synthesizes that philosophy into one place: the guiding principles that shape the system and the inviolable invariants it must never break. It complements — it does not replace — the ADRs; each item cites the ADR(s) it derives from, and should be kept in sync when those are superseded.
Guiding principles
- One uniform
Record, one genericStore("Approach A"). Every domain type is a serde view overRecord { key, kind, revision, parent, body, meta, links }; versioning, concurrency, conflict, and sync are written once in the core (ADR 0002). - Substrate pluggability — the backend is configuration, not API.
fs/git/S3/daemon-client each implement only
Store; moving from local to git/S3/daemon is a config change, not a code change; fs is the zero-dependency default (ADR 0004, 0009). - Optimistic concurrency with explicit, typed conflict surfacing.
put(record, expected_parent_rev); a stale parent yieldsPutResult::Conflict(recoverable, not an error); merge is keyed byRecordKind, andSyncreuses the exact same machinery (ADR 0005). - Layering discipline — capabilities compose over a storage-only core.
Vector, graph, tickets, and knowledge are added as layers, each keyed by
RecordKey; substrates never know about vectors or graphs (ADR 0008, 0010, 0011, 0012). - Minimal-core-touch for new capabilities. A new layer may register a
RecordKindat most — no new core traits or types enter (ADR 0010, 0011). This aligns with the rule of three for promoting anything into the core. - Provider-agnostic boundaries via traits. Embedding goes through
Embedder, tickets throughTicketSource; provider divergence is handled bycapabilities()negotiation, neverif provider == …branches (ADR 0008, 0010). - The conformance suite is the executable contract. One shared conformance
suite that every
Storeimplementation must pass (ADR 0006). - A single canonical schema behind dual transports. gRPC (tonic) and
HTTP/JSON (axum) sit over one service layer;
gonzalo-protois the single schema, so the transports cannot drift (ADR 0007). - Single-facade public surface + workspace discipline. Caliban depends on
one crate — the
gonzalofacade; substrates and layers are toggled by Cargo feature (ADR 0009). 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). - Normalize on the shared spine, preserve the raw losslessly. Tickets
normalize to
State { category, resolution, raw_name, raw_id }plus a boundedfieldsmap; the status signal is configured per connection, not hard-coded per provider (ADR 0010). - Separate storage identity from query identity. The code graph keys slices by content + grammar hash (which dedups) and resolves them through a per-worktree manifest — git's blob/tree split (ADR 0012).
- Retrieval returns first-class records, never bare ids. Vector, graph,
knowledge, and ticket queries resolve back through the
Storeto whole records (ADR 0008, 0011). - Rust-native deliverables; the daemon is the non-Rust boundary. We ship
Rust crates, the
gonzalodimage, 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.protoand an OpenAPI document are attached to each release, so a client is generated and owned by its consumer rather than maintained here. - Deletion is a write, not an absence. A delete writes a tombstone that
syncand gitpullreplicate 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). - 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).
- 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.
gonzalodkeeps authorizing its own API by ADR 0015's token principal, which is unrelated toFleetRole(ADR 0022, ADR 0023). - 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
- Concurrent edits are never silently lost — the core invariant. A
stale-parent write MUST return
Conflict, never overwrite; ambiguous merges MUST surface (ADR 0005). - Conflict is a typed, recoverable result —
PutResult::Conflict, never collapsed intoGonzaloError(ADR 0005). - All persistence funnels through the one
Record/Store— no parallel typed store re-implements versioning (ADR 0002). - Substrates implement only the generic
Storeand stay type-blind — no substrate-specific escape hatches (ADR 0004, 0008). - Every
Storeimplementation must pass the shared conformance suite (ADR 0006). - Capability layers never bypass or mutate the core — a layer may register
a
RecordKind+ merge class at most (ADR 0008, 0010, 0011). Syncreuses the exact local-write conflict/merge machinery — anyStorecan be a sync peer (ADR 0005).- The daemon's two transports derive from one canonical schema + one service
layer (
gonzalo-proto) (ADR 0007). - The code graph is NEVER keyed by
(repo, path)— two-level keying; slices are content-addressed, path-agnostic, and stored raw, and resolution tolerates missing targets (ADR 0012). - No query engine ever sits under the
Storesubstrate — engines back only regenerable index layers, never the durable source of truth (ADR 0012). unsafe_codeis forbidden workspace-wide (ADR 0009).- License is AGPL-3.0-only (ADR 0003).
- The core does no I/O —
gonzalo-coreis pure logic; all I/O lives in substrates (design spec §3). - Every write carries provenance identity — an
Identity;Metarecordsauthorandorigin_system(design spec §4, §9). The store also stampscreatedandupdatedinside 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. - ADRs are an append-only log — superseded, never deleted (ADR 0001).
- A consumer read never shows a tombstone —
getandlisthide 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). - 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
undeleterestore a deleted index (ADR 0029). - 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
| command | does |
|---|---|
gonzalo status --root R | record 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 I | restore 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 I | physically 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
| command | does |
|---|---|
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:
| flag | effect |
|---|---|
--watch | keep running and re-index on filesystem changes until Ctrl-C |
--debounce-ms N | with --watch, quiet period after the last change (default 500) |
--reconcile-secs N | with --watch, seconds between full reconciles (default 300) |
--gc | after indexing, run a whole-store GC (also applied on each reconcile under --watch), reporting gc.freed, gc.retained and gc.deferred |
--include PATH | index a path a built-in rule would skip, such as vendored code. Repeatable. Cannot override .gitignore. |
--require-parse-worker | fail 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
| command | does |
|---|---|
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 indexwrites.gonzalo-mcponly reads.
The server never indexes anything. Install and register it without indexing and you get a server that answers every query with "no indexed view" — correctly, but forever. Indexing is not optional and not discoverable from the server.
gonzalo index ──writes──▶ store root ◀──reads── gonzalo-mcp ◀── agent
(~/.gonzalo)
A view is one indexed snapshot of one repo, addressed by (repo, view_id).
repo is any identifier you choose (by convention owner/name); view_id is
typically a branch. Multiple repos and multiple views share one store root cleanly —
each gets its own SQLite graph under <root>/graphs/<encoded-repo>/<view>.db.
Install
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:
| language | extensions |
|---|---|
| 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, orin-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 thatabort()s will skip one file or kill the run. In-process also prints a warning naming every location searched — theGONZALO_PARSE_WORKERoverride, beside the executable, then eachPATHentry. Pass--require-parse-workerto fail instead.driver—full walkthe first time; afterwards a git-diff-driven incremental pass that re-parses only what changed. A no-op re-index is well under a second.skipped— files a parse worker crashed or hung on.driveralso goes back tofull walkon its own when gonzalo's extraction format changes, so a parser improvement reaches files that did not themselves change. The format recorded against a view is the one the parse worker reported, not the onegonzalowas 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 matchinggonzalo-parse-workerto clear it.ignored— files and directories deliberately left out of the view: dependency and build-output directories, generated bundles (*.min.jsand friends), and anything.gitignored. Use--include <path>to re-admit a vendored path you do want indexed.--includecannot override.gitignore, because a view must stay reproducible from the commit alone.
Keeping a view fresh
Views are snapshots. Re-index after checking out or pulling — it is incremental:
gonzalo index --root /Users/you/.gonzalo --repo acme/widgets --view main /path/to/checkout
gonzalo index --watch keeps a view current continuously, with a debounce and a
periodic full reconcile.
Because views are per-(repo, view_id), git worktrees pair naturally with them: index
each worktree under its own view_id and diff them.
Register
claude mcp add gonzalo --env GONZALO_ROOT=/Users/you/.gonzalo -- gonzalo-mcp
A leading
~inGONZALO_ROOT(and in--root) is expanded to$HOME, soGONZALO_ROOT=~/.gonzaloworks even though no shell is involved — neither bash nor zsh expands a tilde in--env KEY=~/pathargument 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).statusreports 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
| tool | answers |
|---|---|
views | which (repo, view_id) pairs exist, with file counts and indexed commit |
status | is the server up, which root, how many views |
Whole-view — start here in an unfamiliar repo; none need a symbol name.
| tool | answers |
|---|---|
overview | counts, breakdown by kind and language, largest files |
top | most referenced (fan_in), most calls out (fan_out), most ambiguous (definitions) |
list | enumerate symbols filtered by path prefix, kind, name substring |
unreferenced | dead-code candidates — heuristic, see below |
Per-symbol — for a name you already have.
| tool | answers |
|---|---|
search | where is this defined |
node | definitions + callers + callees in one call |
callers / callees | who calls this / what does this call |
explore | every reference, with paths |
impact | transitive caller closure, resolution-gated; takes max_depth |
Across views
| tool | answers |
|---|---|
diff | symbols and references added/removed from view_a to view_b |
Records — the store itself, not the code graph. No view selector; these
address a record by namespace, collection and id.
| tool | answers |
|---|---|
record_get | read one record: body, kind, revision, provenance |
record_list | the keys in a namespace, or in one collection of it |
record_put | write a record — only when the server allows writes |
record_delete | delete 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.
| tool | other parameters |
|---|---|
search, node, callers, callees, explore | name (required) |
impact | name (required); max_depth, where 0 or omitted means unbounded |
diff | view_a, view_b (both required) |
overview | largest: how many of the largest files to list (default 20) |
top | by (required): fan_in, fan_out or definitions; limit (default 20) |
list | path_prefix, kind, name_contains, limit (default 100) |
unreferenced | path_prefix, kind, name_contains, exclude_tests (default true), limit (default 100) |
The record tools take no view selector:
| tool | parameters |
|---|---|
record_get, record_delete | namespace, collection, id (all required) |
record_list | namespace (required); collection to narrow it |
record_put | namespace, 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
| symptom | cause |
|---|---|
| server will not start | ~/.cargo/bin not on PATH for non-interactive shells — set it in ~/.zshenv or register an absolute path |
status shows "views":0 | nothing indexed yet, or GONZALO_ROOT points somewhere unexpected — check the root it reports |
a directory named ~ appeared | GONZALO_ROOT=~/… was not expanded; use an absolute path |
| "no indexed view" error | the selector does not match — call views |
| results describe code that changed | stale view — compare base_commit to HEAD and re-index |
| new tools missing after an upgrade | the client is still running the old binary — reconnect the MCP server |
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.
| variable | purpose | default |
|---|---|---|
GONZALO_STORE | substrate: fs or s3 | fs |
GONZALO_ROOT | fs store root | ./gonzalo-data (image: /data) |
GONZALO_S3_BUCKET | bucket, required when GONZALO_STORE=s3 | none |
GONZALO_S3_ENDPOINT | S3 endpoint for a self-hosted store such as RustFS | ambient AWS config |
GONZALO_S3_REGION | region override | ambient AWS config |
AWS_* | S3 credentials, read the standard way | none |
GONZALO_ANCESTOR_CAP | revisions kept in each record's ancestor list (see Ancestor cap) | 32 |
GONZALO_HTTP_ADDR | HTTP bind address | 127.0.0.1:8080 (image: 0.0.0.0:8080) |
GONZALO_GRPC_ADDR | gRPC bind address | 127.0.0.1:50051 (image: 0.0.0.0:50051) |
GONZALO_MAX_BLOB_SIZE | max bytes per blob over the transports | 64 MiB |
GONZALO_AUTH_FILE | TOML principals file for namespace-scoped auth | unset |
GONZALO_TOKEN | single admin token, used only when no auth file is set | unset |
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):
GONZALO_AUTH_FILEset: parse it as a principals file.- Otherwise
GONZALO_TOKENset: one admin principal namedrootwith that token. - 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:
| operation | needs |
|---|---|
| get a record | read on its namespace |
| put or delete a record | write on its namespace |
| list keys in a namespace | read on that namespace |
| list keys with no namespace filter | read on "*" |
| raw get a record | read on its namespace |
| raw put a record | admin ("*" in both lists) |
| raw key listing | as list keys |
| purge a record | admin ("*" in both lists), and only a tombstone |
| code-graph queries | read on the view's repo |
| blobs | read / write on the reserved _blobs namespace |
| ticket sync | write on tickets |
/healthz, /readyz | nothing: 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 path | does |
|---|---|
GET /healthz | liveness: always 200 ok |
GET /readyz | readiness: 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/blobs | list blobs, as objects carrying each blob's hash and modified time |
POST /v1/tickets/sync | sync 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:
| file | describes | for |
|---|---|---|
gonzalo-vX.Y.Z.proto | the gRPC surface | protoc, buf, and every gRPC toolchain |
gonzalo-openapi-vX.Y.Z.json | the HTTP/JSON surface | OpenAPI 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.
PUTanswers200with{"outcome": "conflict", …}carrying the record the store actually holds (ADR 0005). Re-read, re-apply, retry — never retry blind. expectedis 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/...returns404whileGET /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):
| feature | brings in |
|---|---|
fs (default) | gonzalo-store-fs, the filesystem substrate |
git | gonzalo-store-git, the git-backed substrate |
s3 | gonzalo-store-s3, the S3-compatible substrate |
remote | gonzalo-store-server, a client Store over a running gonzalod |
vector | gonzalo-vector: the Embedder trait and vector indexes |
knowledge | gonzalo-knowledge: records plus vector search by RecordKey (ADR 0011); implies vector |
graph | gonzalo-graph: the tree-sitter code graph |
ticket, ticket-github, ticket-jira, ticket-linear, ticket-gitlab, ticket-asana | the ticket layer and its connectors |
The core and gonzalo-domain (typed views for memory tiers, topics, sessions,
checkpoints, tickets and fleet access-control records) are
always included.
The surface is grouped into modules (ADR 0026).
The root holds the record and store core — Record, RecordKey, Store,
PutResult, the operations (sync, merge, collect, reset, gc_blobs)
and the substrates. Every domain type lives under the module naming its domain:
| module | holds |
|---|---|
gonzalo::memory | MemoryTier, Topic |
gonzalo::session | Session, Turn |
gonzalo::checkpoint | Checkpoint |
gonzalo::codec | RecordCodec |
gonzalo::ticket | the ticket records, plus the source layer and its connectors |
gonzalo::fleet | the fleet access-control records |
gonzalo::graph, ::vector, ::knowledge | the feature-gated capability layers |
So a program with its own State or Actor keeps them: gonzalo's are
gonzalo::ticket::State and gonzalo::ticket::Actor. Up to and including 0.7.0
all of these were exported flat at the root; the grouping lands in the next
release, with no deprecation window, and the changelog entry carries the full
old→new table. Each ticket-* connector feature implies ticket, and
knowledge implies vector.
Choosing a substrate
| substrate | good for | notes |
|---|---|---|
| fs | a single machine; the CLI and gonzalo-mcp | zero dependencies. Per-record file locks make concurrent writers on one host safe. Two fs stores reconcile with gonzalo sync. |
| git | sharing state between machines or people through an ordinary git remote | commits every write. GitStore::pull(remote, branch) merges non-fast-forward histories by record content (ADR 0017); push publishes. Not a daemon substrate, and not a blob store. |
| s3 | several stateless gonzalod replicas over shared storage | the backend must support atomic If-Match conditional writes. See below. |
| remote | a Rust program using a daemon someone else runs | HTTP or gRPC, optional bearer token. See Running gonzalod. |
The daemon can serve fs or s3 (GONZALO_STORE). The CLI and gonzalo-mcp work on
fs roots.
S3: only atomic conditional writes are safe
Optimistic concurrency over S3 depends on the object store enforcing If-Match
atomically. "S3-compatible" does not imply that. A store that checks and then sets
lets concurrent writers all commit, and updates are lost without any conflict being
reported, which is the one outcome gonzalo exists to prevent
(ADR 0019).
| backend | atomic If-Match | status |
|---|---|---|
| RustFS | yes | the qualified backend for multi-replica HA |
| MinIO | yes | technically sound; not chosen, for project-sustainability reasons |
| Garage | no | unsafe: lets several racers commit |
For AWS S3 itself, set GONZALO_S3_BUCKET with no endpoint and let the ambient AWS
configuration supply region and credentials. ADR 0019's qualification runs did not
cover it.
Qualify any other backend by running gonzalo-store-s3's conditional-write
conformance case against it, not by reading its compatibility matrix.
scripts/rustfs-up.sh starts a pinned single-node RustFS for local testing, and the
gonzalo-soak crate runs replicas against it while killing them.
S3: what a listing costs
A tombstone lives at the deleted record's own object key, so a listing can't tell
it from a live record by the record alone. A delete therefore also writes a
marker beside it, at <object key>.tombstone, and list makes one
ListObjectsV2 pass and reads only the keys a marker points at
(ADR 0025). The cost is one read per
tombstone, which collect bounds — not one per record, which nothing bounds.
reset and collect enumerate the same way and get the same reduction.
A bucket written before markers existed upgrades itself. Until a collection
carries the flag object <namespace>/<collection>/_tombstone_markers, list
reads every key as it always did, writes the markers it finds missing, and then
sets the flag. So the first listing of each collection after upgrading costs
what it used to, every later one is cheap, and at no point can a deleted record
reappear in a listing. There is nothing to run and nothing to remember.
Concurrency and conflicts
Writes are optimistic. put(record, expected) names the revision the caller believes
is current (None for "expect no record"). If the store holds something else, the
result is PutResult::Conflict carrying the live record. A conflict is a normal,
typed, recoverable result, never an error and never a silent overwrite
(ADR 0005).
Sync uses the same machinery. Records carry content-addressed ancestry, so two diverged copies merge 3-way against their common ancestor, with the merge rule chosen by record kind; append-only kinds auto-merge (ADR 0016).
Deletion
Store::delete(key, expected) is OCC-aware in the same way as put: with Some(rev)
it deletes only if rev is still current, and otherwise returns
DeleteResult::Conflict with the live record. Deleting an absent key is an idempotent
success.
A delete writes a tombstone that sync and git pull replicate, so a delete
made on one store takes effect on its peers. Consumer reads (get, list) hide
tombstones; gonzalo collect removes old ones. See
Deleting records, resetting namespaces, and collecting tombstones
and ADR 0021, which supersedes
ADR 0018's local-only deletion. Deletion replicates only once every binary that
reads the store or runs sync is on 0.7.
Blobs
The fs and S3 substrates also implement BlobStore, a content-addressed store for
large bodies. Blobs are keyed by content hash and written only if absent, so storing
the same bytes twice is a no-op. Code-graph slices shared across views are stored
this way (ADR 0012).
Because blobs are shared, nothing frees one implicitly — not deleting the record
that holds it, and not collecting that record's tombstone, which pins the blob
until it is collected. gonzalo gc sweeps what no record references, marking
record bodies, tombstone pins and manifest slices alike
(ADR 0024). Deletion
walks through the order.
Deleting a vector index (or a code-graph view) is recoverable within the
collection horizon: the manifest's tombstone keeps the manifest body, so its
shards stay marked and gonzalo undelete restores it
(ADR 0029). Reopening a deleted index is
refused, so the restore window survives; the two ways out (undelete or purge)
are in Deletion.
Vector search and embeddings
gonzalo-vector defines the Embedder trait and an exact in-memory index,
MemoryVectorIndex. Its hnsw feature adds HnswVectorIndex, an approximate index
for larger collections (ADR 0014).
gonzalo-embed provides a local CPU embedder built on Candle and all-MiniLM
(ADR 0013). It is a separate crate, not a
facade feature.
A durable vector index
RecordVectorIndex persists vectors behind the same VectorIndex trait, so
recall survives the process that built it — the working case is
caller-supplied embeddings (an embedder gonzalo never runs, so it can never
rebuild an index by re-embedding) (ADR 0027).
Vectors are bucketed into a fixed number of shards (256 by default), each one a content-addressed blob, named by a vector manifest record. A manifest records the embedding space, the dimension, the shard count, and which blob holds each shard's vectors — the same content-addressing the rest of gonzalo uses for blobs, so an unchanged shard costs nothing extra to rewrite.
open(store, key, space, dim) declares an embedding space and dimension.
Opening against a manifest whose stored space or dimension disagrees is an
error naming both values: a swapped embedder is caught once, loudly, at
startup, rather than producing silently wrong rankings on every query after.
This is a declared value, not a verified one — gonzalo never sees the model
that produced a vector, so it can catch configuration drift but not a caller
that mislabels its vectors on purpose or by mistake.
One writer per index. Optimistic concurrency detects a second writer and retries; it does not merge two writers' changes. A second writer's queries can be served from a shard it hasn't refreshed yet until it reopens, though a concurrent writer's actual commits are never silently lost — a conflict reloads every shard that changed and retries.
gonzalo gc already treats a vector manifest's shard blobs as live, the same
way it treats a code-graph manifest's slices, so running GC against a store
holding a durable vector index is much safer than it used to be while writers
are running, but not risk-free. A sweep skips blobs younger than --min-age
(one hour by default), and a writer re-checks the blobs it newly referenced
after its manifest commits (ADR 0028). Two windows remain. A writer
that crashes between its manifest commit and that re-check can still lose a
newly referenced shard to a sweep. And a sweep that has already decided to
delete an old shard blob deletes it even if a live writer references it a
moment later, because the writer's re-check still sees the blob. Either loss is
unrecoverable for vectors, since they cannot be regenerated the way graph
slices can — see
ADR 0027.
The whole index loads into memory when opened — there is no partial or paged
load — so this is the right shape for the thousands-to-~100k-chunk working
set, not for scale beyond it; an external backend (#202) is the answer there,
still behind the unchanged VectorIndex trait.
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
gonzalodstill gets, puts, lists and deletes normally. Replication reads fail withdaemon predates replication reads (gonzalo#203); upgrade gonzalodinstead 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
| Code | Meaning |
|---|---|
0 | Success. For collect, conflicts are reported but aren't failures |
1 | Error: I/O, the store, or --ancestor-cap 0 (ancestor cap must be at least 1) |
2 | Usage error: a missing or malformed argument |
3 | Conflict (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 setup | Longest realistic gap between syncs | Suggested --older-than |
|---|---|---|
| One store, never synced with anything | none | any, e.g. 7d |
| A few machines that sync with a daemon daily | a long weekend, a holiday | 30d–60d |
| Laptops that can be offline for weeks (travel, leave) | several weeks | 90d or more |
| Peers you don't control, or you don't know | unknown | don'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
collectfrom 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:
deletethe record — the tombstone appears, holding the blob;collectpast the horizon — the tombstone goes, releasing the pin;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::openrefuses 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 nextgonzalo gcreclaims.
Warning. Upgrade every binary that runs
gonzalo gcagainst a store before you rely on this pin. An older binary reads a tombstone that carriesdeleted_kindwithout complaint, but it does not mark through the retained body, so itsgcfrees 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
- 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:
| Operation | HTTP | Permission |
|---|---|---|
| Delete (writes a tombstone; deleter as above) | DELETE /v1/records/{ns}/{col}/{id} | write on the namespace |
| Raw read of one record | GET /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 listing | GET /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
| Kind | For |
|---|---|
Person | A human with fleet roles, keyed by an opaque id the minting consumer generates — never an email or a platform id. |
IdentityBinding | Ties one external account (a Discord user, an OIDC subject, …) to a Person. |
RoleGrant | One person's role for one scope (the whole fleet, or one workspace). Revoking a role is a delete, not an edit. |
ChannelConfig | A chat channel's configuration: what it follows, how it's notified, and the highest role a command in it may run with. |
LinkToken | A one-time, expiring token that lets a person claim an external account. |
AuditEntry | One write-once record of who did what, to what, when, and with what result. |
Two namespaces
Records live in two namespaces:
fleetholds the mutable access-control state: people, identity bindings, role grants, channel configs and link tokens.fleet-auditholds 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 :.
| Kind | Key |
|---|---|
Person | fleet/people/<person_id> |
IdentityBinding | fleet/identity-bindings/<authenticator>:<subject> |
RoleGrant | fleet/role-grants/<person_id>:<scope> |
ChannelConfig | fleet/channels/<provider>:<tenant>:<channel> |
LinkToken | fleet/link-tokens/<token_hash> |
AuditEntry | fleet-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.
Link tokens
- Only a hash is stored.
LinkToken.token_hashis 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.
redeemchecks the secret, that the token hasn't expired, and that it isn't already consumed, then returns a copy withconsumedset, written with the read revision asexpectedso two concurrent redemptions produce oneCommittedand oneConflict. 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,RoleGrantandChannelConfig— areStructured. With a real common base (git's merge base underpull, or a retained shared parent undersync_with_ancestry), separate field edits merge and the same field edited two ways conflicts. LinkTokenandAuditEntryareOpaque. ALinkTokenredemption 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. AnAuditEntryis 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, aRoleGrant'sscope, and aChannelConfig'sfollowsare 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
synchas no common base. Unlikepullandsync_with_ancestry, plainsyncdoesn't retain a shared ancestor, so under it every concurrent edit to one of the fourStructuredkinds — not just the atomically-wrapped fields — surfaces as a conflict rather than merging field by field. Choosepullorsync_with_ancestrywhen 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 purgeremoves 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.
Recordgains an additivedeleted_kindfield (tombstones only; absent on every existing record). AGraphManifestorVectorManifesttombstone now retains the deleted body instead of discarding it, andgonzalo gcmarks through that body, so a deleted index's slices or shards survive untilcollectremoves the tombstone.gonzalo_core::undelete, thegonzalo undeletesubcommand and the facade re-export restore a manifest from its tombstone, preservingmeta.created. Aputthat creates over a manifest tombstone is refused (see below). An older binary'sgcdoes not readdeleted_kindand frees the shards the tombstone pins, so upgrade every binary that runsgcagainst a store first. (ADR 0029, #327) BlobStore::has_blob. A defaulted method (get_blob(..).is_some()), overridden by the filesystem store withtry_exists, by S3 withHeadObject, and by the remote store with the presence route below. A failure that is not "no such key" is an error, notfalse. (#325, #329)HEAD /v1/blobs/{hash}and theHasBlobgRPC RPC answer whether a blob exists without reading it, andServerStore::has_blobnow 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 answered200/404, because axum servesHEADfrom theGEThandler and discards the body; it now reportscontent-length: 0rather than the blob's size, since a size needs the read the route exists to avoid.GETthe blob if you need its length. Over gRPC a daemon with noHasBlobRPC earnsDAEMON_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 servesHEADfrom a route'sGEThandler and an older daemon therefore answers anyway. (ADR 0028, #329)- A sweep policy for blob GC.
SweepPolicy,DEFAULT_MIN_AGE(one hour),sweep_blobs_withandgc_blobs_withlet a caller set the minimum blob age explicitly;gc_blobsandsweep_blobskeep their signatures and use the default.GcReportgainsdeferred, the unreferenced blobs held back for being too young, andfreednow comes back in hash order. (#325) gonzalo gc --min-age <duration>(default1h, same spellings ascollect --older-than: one positive whole number and one unit ofd,h,mors). Thegcandindex --gcsummaries printdeferred. (#325)- Vectors now survive the process that built them. A new
RecordVectorIndexpersists aVectorIndexbehind sharded, content-addressed blobs named by a newRecordKind::VectorManifestrecord (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 takestd::num::NonZeroU16, so a zero shard count is unrepresentable rather than a runtime panic waiting to happen.gonzalo gcmarks 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. Thegonzalofacade'svectormodule (behind thevectorfeature) re-exportsRecordVectorIndex,DEFAULT_SHARDS,shard_ofandVectorManifest, so a facade consumer can reach the durable index without depending ongonzalo-vector/gonzalo-coredirectly. See ADR 0027. (#323) KnowledgeStore::openrebuilds chunk counts from a durable index.KnowledgeStore.chunk_countsonly 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 newVectorIndex::keys(below) to rebuild counts at construction;KnowledgeStore::newis unchanged and still starts counts empty, which is correct for a fresh in-memory index. (#323)- Records carry their times.
Meta.createdandMeta.updatedwere always0; the store now stamps both inside the same critical section that decides the write, exactly as it stamps a tombstone'sdeleted_at.createdsurvives every edit, a delete counts as the record's last update (itsupdatedequals the tombstone'sdeleted_at), and a recreation over a tombstone starts a newcreated, 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::createmakes a record that doesn't exist yet, andrecord.update(body, meta)makes the next version of one you read: it sets the revision to follow andparentto 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 gcnow 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 newRecord.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,collectpast the horizon, thengc.gc_blobstakes the store and does its own listing;sweep_blobsandlive_blob_hashesare 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-mcpexposed 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 exposesrecord_getandrecord_list, withrecord_putandrecord_deletebehind--allow-writes(orGONZALO_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>, withGONZALO_TOKEN) points the server at a runninggonzalodinstead 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-protowas 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 attachesgonzalo-vX.Y.Z.protoandgonzalo-openapi-vX.Y.Z.jsonto its GitHub Release, and the OpenAPI document lives in-tree atdocs/api/openapi.json. They cannot drift:SERVED_OPERATIONSis 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::openrefuses 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 sawopensucceed, queries return nothing, and the real explanation arrive on the next upsert.opennow reads raw and errors withCoreError::Invalid, naming the key and the two ways out (gonzalo undelete,gonzalo purge). The trigger is the same conditionplan_putuses — the stored tombstone'sdeleted_kindnaming a manifest kind — soopenrefuses 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 forgc. (ADR 0029, ADR 0030, #340) -
BREAKING: the raw replication write requires an admin token.
PUT /v1/raw/records/{ns}/{col}/{id}and thePutRawRPC authorizedwriteon the namespace; they now require admin, matchingPOST /v1/purge/...andPurge. A replication peer configured with a non-admin token begins getting403/PermissionDeniedand 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 theplan_putguard added above, which let a principal with plainWritediscard a deleted manifest's restore window by aiming a fresh record at the tombstone's revision — predictable, becausetombstone_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 thePurgeRPC now answer400/InvalidArgumentwhen the named revision matches a live record, asgonzalo purgehas 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; usegonzalo deleteand 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 answers409 Conflictand removes nothing, whichcollectrecords and carries on from. (ADR 0031, #341) -
A
putthat creates over a manifest tombstone is now refused. A consumer create (expected: None) at a key holding aGraphManifestorVectorManifesttombstone used to succeed, overwriting the tombstone and discarding the retained body and the shard pin. It now fails withCoreError::Invalid(HTTP 400 / gRPCInvalidArgument), naminggonzalo undeleteandgonzalo purge. This affectsRecordVectorIndexcommits andgonzalo indexover a deleted manifest, sogonzalo resetfollowed bygonzalo indexnow needs a purge or undelete first. Replication writes andundeleteare unchanged.RecordVectorIndex::openstill 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_slicesandgonzalo_core::live_slice_hashesare removed. After #325 rewrote the sweep, neither had a caller outside its own unit tests —live_blob_hashesinlines the manifest-entry logic rather than callinglive_slice_hashes, andsweep_blobs_withdoes its own retain loop. Both were also footguns:unreferenced_slicesis age-unaware, so mappinglist_blobs()down to hashes and feeding it rebuilt the unsafe delete list #325 removed, andlive_slice_hashesused alone as a mark set keeps only manifest slices and sweeps every record body. Usegc_blobs_with/sweep_blobs_withto sweep andlive_blob_hashesto mark. Thegonzalofacade never re-exported either, so only a directgonzalo-coredependant is affected. (#331) -
BREAKING: the indexing knobs move into
gonzalo_cli::IndexOptions.index_with_gc_filteredandindex_with_gc_filtered_workerare replaced byindex_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_gcis unchanged.IndexOptions::default()deliberately setsmin_agetoDEFAULT_MIN_AGErather than the derivedDuration::ZERO— a zero default would let a caller who setsgc_afterreinstate the age-unaware sweep ADR 0028 removed. (#331) -
BREAKING:
IngestError::Conflictis a boxed tuple variant. It wasConflict { key, expected, current }with the fields inlined, which made the error path of everyResultingonzalo-ticket's ingest module 136 bytes wide — clippy 1.99'sresult_large_err. It is nowConflict(Box<gonzalo_core::Conflict>), the same shapePutResult::Conflictalready 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.tomlmoved 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-trait0.1.89 → 0.1.92 (upstream stopped emitting the#[must_use]that made clippy'sdouble_must_usefire on 14 desugaredasync fns ingonzalo-core— preferred to an#[allow], which would also have masked the lint for hand-written code, and it pulls insyn 3.0.6);as_chunks::<2>()forchunks_exact(2)ingonzalo-domain's hex parser; theIngestErrorboxing above; aresult_large_errallow scoped togonzalo-proto's generatedv1module, where tonic'sResult<_, Status>signatures are build-script output and not editable; andslice::fillfor a manual loop ingonzalo-soak's oracle tests. (#336) -
BREAKING:
BlobStore::list_blobsreturnsVec<BlobEntry>. Each entry carries the blob's hash andmodified_unix_ms, which the sweep needs to tell a freshly uploaded blob from an abandoned one. Any out-of-treeBlobStoreimplementor must change its signature. (#325) -
BREAKING: the daemon's blob listing returns objects. HTTP
GET /v1/blobsreturns[{"hash": …, "modified_unix_ms": …}]instead of["…"], and gRPCListBlobsResponsereserves field 1 and addsrepeated BlobEntry entries = 2, so an old gRPC client sees an empty list and a GC through it deletes nothing.docs/api/openapi.jsongains aBlobEntryschema; 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:
VectorIndexgains a requiredkeysmethod. There was no way to enumerate an arbitrary index's contents, which is whatKnowledgeStore::openneeds to rebuild derived state — its per-record chunk counts — after a restart (gonzalo gcnever callskeys; it marks a vector manifest's shard blobs directly).keyshas no default implementation and must be added by any out-of-treeVectorIndeximplementor.upsert_manywas also added, but as a default that loops overupsert, so it is not itself breaking —RecordVectorIndexoverrides it to batch a bulk load into one manifest commit instead of one per vector, and the newimpl VectorIndex for Arc<T>forwards both methods (includingupsert_many, explicitly, so a sharedArchandle doesn't silently fall back to the looping default). See ADR 0027. (#323) -
gonzalo-vectornow requires a tokio runtime.RecordVectorIndex::openreads shard blobs concurrently viatokio::task::JoinSet::spawn, which panics outside a tokio runtime.tokio(featurert) 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 ofgonzalo-vectorwould need one to callopen. (#323) -
BREAKING: the facade's surface is grouped into modules.
gonzalore-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-ticketandgonzalo-graphboth define aPage, 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 structuregonzalo-domainalready had. There is no deprecation window — the flat names are gone in this release — so the mapping is below. See ADR 0026. (#319)was now gonzalo::MemoryTier,Topicgonzalo::memory::{MemoryTier, Topic}gonzalo::Session,Turngonzalo::session::{Session, Turn}gonzalo::Checkpointgonzalo::checkpoint::Checkpointgonzalo::RecordCodecgonzalo::codec::RecordCodecgonzalo::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, andFsStore/GitStore/S3Store/ServerStore. -
S3 listings stop paying a read per record. Hiding tombstones from
listcost aGetObjectper key, because a tombstone lives at the deleted record's own object key and only its body says so;resetandcollectinherited it. A delete now also writes a small marker beside the record, so oneListObjectsV2pass classifies every key and only marked keys are read — one read per tombstone, whichcollectbounds, 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,listreads 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
HeadObjectfault was described as "unhandled error". A failed blob presence check surfacedHeadObjectError's ownDisplay, 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::ingestcommitted once per chunk on a durable index.ingestloopedupsertper chunk. OnMemoryVectorIndexthat is cheap; on theRecordVectorIndexadded 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 forgc. 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 oneupsert_manyper 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 newVectorIndexmethod, which cannot be called fromgonzalo-knowledgeuntil the workspace version is bumped, becausecargo publish --dry-runverifies each packaged crate against the publishedgonzalo-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(orgonzalo index --gcunder--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 gcno 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 aConflictfor the caller's own write; aGetObjectthat returns no ETag is now an error instead of an emptyIf-Match, which a permissive server could read as "no condition" and turn every compare-and-swap into a blind overwrite; a409retries after a jittered, bounded delay rather than immediately, so a burst cannot spend all eight attempts inside one in-flight window; and consumerlistreads 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 publishingmc, which had already broken CI once. (#286) -
Git
pullhonours ancestor ordering, likesync. 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 throughsyncand 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 anOpaquebody, a checkpoint say, conflicted too, whilesyncfast-forwarded both. Pull now checks ancestry first and takes the side that is ahead, naming those keys in the newPullReport.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, whilegetandlistread 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.mdlistedGONZALO_AUTH_FILEbut never said what goes in it. It now documents the mode precedence, the principals file with an example, the_blobsnamespace for blob access, the 401/403 responses on both transports, and how writes are stamped with the principal's name. It also listsGONZALO_ANCESTOR_CAP. (#281) - The
gonzalocrate 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 throughRecordCodec. 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, andsyncand gitpullpropagate it, so a delete on one store sticks everywhere instead of coming back on the next sync. Tombstones carrydeleted_at(ms since the Unix epoch), and records carry a boundedancestorslist. 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 togonzalo-cli.--expectedis therevisionobject fromgonzalo get's JSON output (e.g.{"counter":3,"hash":"…"}).- On success, including an absent or already-deleted record, prints
deleted: N/C/Iand exits0. - On a stale
--expected, printsconflict: N/C/Iandcurrent: {…}and exits3. - Malformed
--expectedexits2.
(#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. PrintsX deleted, M conflicts, plus oneconflict: <ns/col/id>line per conflict on stderr. When there are conflicts, it also prints a finalre-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 ofd,h,mors(e.g.30d), with no default. Zero, a missing, compound or unknown unit, or overflow exits2, as does--collectionwithout--namespace.- It prints
horizon:,purged:,unstamped:andconflicts:lines, and exits0even 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,resetandcollect:--root <DIR>(default., expands a leading~) and--ancestor-cap <K>(default 32).0exits1withancestor cap must be at least 1.- Exit codes:
0success,1error,2usage error,3conflict (deleteandreset). Each command's--helplists its codes. - The CLI operates on a local fs store only.
(#203)
-
Replication surface on
Store:get_rawandlist_rawshow 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 stampsPrincipal::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 HTTPDELETEbody and the gRPCDeleteRequestgain an optionalauthor/author_jsonclaim, whichServerStore::delete_assends. CLIdeleteandresetboth stampgonzalo-cli. Libraryreset, and the provideddeleteit uses by default, keep each record's own last author instead;reset_asnames a deleter, the same waydelete_asdoes. (#203) -
Daemon routes:
GET /v1/raw/records/{ns}/{col}/{id}andGET /v1/raw/keys(readon the namespace;readon*when unscoped);PUT /v1/raw/records/{ns}/{col}/{id}(writeon the namespace);POST /v1/purge/{ns}/{col}/{id}(admin);- the matching
GetRaw,ListRaw,PutRawandPurgeRPCs.
(#203)
-
Ancestor cap:
--ancestor-cap <n>ongonzalo delete,reset,collectandsync, and theGONZALO_ANCESTOR_CAPenvironment variable ongonzalod, 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_asnames 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 thegonzalofacade 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,LinkTokenandAuditEntry. Typed views live ingonzalo-domain'sfleetmodule and are re-exported by thegonzalofacade. Records sit in thefleetnamespace, with audit entries infleet-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 channelfollows(the whole fleet, or a non-empty set of workspaces), anotifypreset ofall,terminalorfailures, and a roleceiling. Grants scope to the fleet or to one workspace. See ADR 0022 and ADR 0023. (#278) -
SyncReportsays when sync did not converge. A newunconvergedfield lists the keys whose write was still losing a race when sync gave up after its pass limit, andSyncReport::converged()reports whether any pass landed cleanly. Before this, a run that never won a race returned an emptyconflictslist, which read as "in sync" while the two stores still disagreed.gonzalo syncprintsunconvergedand warns on stderr. (#290)
Changed
-
Breaking:
CoreErrorgainsInvalid. A call the store will never accept — a consumerputof a tombstone record, which must go throughdelete_as— now returnsCoreError::Invalidinstead ofBackend. The daemon answers400(gRPCInvalidArgument) rather than an opaque500, andServerStorerestores it asInvalid, so a store behind a daemon fails the way a local one does. Code matching exhaustively onCoreErrormust add the variant. (#299) -
gonzalo syncnames the keys it could not settle. It printed counts only, so an operator learned a conflict existed but not where; it now prints aconflict: <key>orunconverged: <key>line per key on stderr, asdelete,resetandcollectalready do, andSyncSummary'sconflictsandunconvergedfields carry the keys rather than counts.sync --helpgained the exit-code section every other command has, andmigratenow names the directory it could not read. (#299) -
The
gonzalofacade re-exportsBlobStore,AncestryStore,sync_with_ancestryandnow_ms. WithoutBlobStore,Body::Blobcould 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 ongonzalo-coredirectly. (#299) -
gonzalo syncnow 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 whatdelete,resetandcollectalready signal with exit 3. A sync that converges with no conflicts still exits 0. (#290) -
Breaking:
gonzalo-core'sStoretrait gains required methods (get_raw,list_raw,put_raw,purge,delete_as),deletebecomes a provided method overdelete_as, andRecordKindgainsTombstone, so this release is 0.7.0 across the workspace. The replication methods have no default implementation on purpose:get_raw = getwould compile, pass every test that doesn't delete, and resurrect records in production. Third-partyStoreimplementations get a compile error naming exactly what to add, and must implementdelete_asinstead ofdelete. (#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-Matchrace —PreconditionFailed(412),ConditionalRequestConflict(409), orNoSuchKey(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::deletewrites a tombstone instead of removing the record.getandlisthide tombstones, so applications see no difference. Deleting an already-deleted or absent key is still an idempotentDeleted. 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
putwith 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.putstill returns the stored revision; a caller that ignores it and reuses its own will get a conflict on its next conditional write. Aputwith any expected revision over a deleted key returnsNotFound. (#203) -
The daemon returns 412 for a write rejected as not found. A consumer
PUT /v1/records/{ns}/{col}/{id}(or rawPUT) whose expected revision the store rejects withNotFound, such as a conditional write over a deleted key, now returns HTTP 412 (gRPCFailedPrecondition) instead of an opaque 500 (Internal).ServerStoremaps it back toCoreError::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_rawfrom a non-admin principal hasmeta.authorrestamped to that principal, like a consumerput. 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_binstead ofmerged. Before, anOpaquekind such asCheckpointreported a conflict even though nothing had diverged. A delete racing an edit of the same revision is reported as aSyncConflictwith neither side written. Records written before 0.7 have no ancestors and take the old merge path unchanged.gonzalo syncnow prints afast_forwarded: Nline, and its output labels are re-aligned to the width of that line. (#203) -
git
pullhandles tombstones on paths changed on both sides: concurrent tombstones converge on the newer one, and delete-versus-edit is reported as aPullConflictthat keeps local. (#203) -
The HA soak and s3 conformance require RustFS ≥ 1.0.0-rc.6, whose conditional
DeleteObjectis atomic; earlier releases can let a purge remove a concurrent recreation.docker-compose.rustfs.ymlpins it. (#203) -
The HA soak adds replicated-deletion invariants. It races deletes against edits and recreations across
gonzalodreplicas 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 togonzalo-coreandgonzalo-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
Deleteandresetrows, 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 afterv0.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 —
searchfinds none of its symbols,callersmisses every call it makes — so the one line saying it happened has to say where.The message names the path, and
IndexSummarycarriesskipped_pathsso 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) -
.hheaders are parsed as C++, not C..his 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 };recordedColoras a function and invented a symbol literally namedclass.Found by indexing a real C++ project (2017 files):
before after symbols extracted 82331 88385 symbols from .hfiles22951 29005 symbols named class372 0 keyword-shaped names in total 470 26 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 namespaceinside a#definebody, 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.
.hfiles also now report ascpprather thancinoverview's language breakdown.EXTRACTION_VERSIONis 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 inskipped, not inignored— 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 treeThe count is distinct from
skipped(a parse worker crashed on it) andignored(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, soignoredis 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 inkeyrather thanname.Measured by re-indexing a real Next.js app:
before after symbols extracted 482 638 production references with a caller 1799 2247 production references orphaned 589 141 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 likeexport 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 keepsitems.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_VERSIONis 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
callersnorimpact.They are now attributed to the nearest named binding. Since that widens what a reference's
frommeans, the scope travels with it rather than the meaning changing silently:Reference::from_scopedistinguishesFunctionfromModule, and the MCPcallerstool keeps its exact previous meaning — functions that call this — with the module-level ones in a newmodule_callers.On a real Next.js/tRPC codebase (815 files):
before after production references with a caller 20754 27524 production references orphaned 7795 1025 production orphan rate 27% 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 aPORT:key reportsPORTrather than the enclosing schema. Deliberately over-inclusive in the same wayRefKind::Valueis, 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'sstaticinitializers are the same shape and each needs its own evidence.EXTRACTION_VERSIONis 13. (#268) -
CommonJS
require()is recorded as an import. JavaScript and TypeScript extraction handled the ESimportstatement 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 ESasalias already does. A computedrequire(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')recordsutil/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_VERSIONis 12. (#269) -
C and C++
#includedirectives 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_filesays 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 includingrender/pipeline.hresolves torender/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_VERSIONis 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 Actionresolves 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:
repo candidates resolved before after CS-5260 (Python) 85 0 65 cs-395-spring-2024 29 14 19 cs-5253-fall-2023 701 0 185 cs-5254-summer-2023 679 0 49 Confirmed through the server rather than only in tests:
impacton 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_VERSIONis 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.
repo files imports before after cs-395-spring-2024 401 0 2657 cs-5253-fall-2023 728 0 6620 cs-5254-summer-2023 752 0 5642 A Java static import records the member it brings into scope rather than the class holding it, and a Kotlin
asalias records under its local name. A wildcard records nothing, as with Rust's glob. Kotlin needed one wrinkle: its grammar drops the*, soimport a.b.*andimport a.bparse identically and the source text is the only way to tell them apart.Extraction only — resolution already consumed imports generically.
EXTRACTION_VERSIONis 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::makeprefers themakeundermodel.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, asrcdirectory is build layout rather than a module, and-and_compare alike so a crate writtengonzalo_climatchesgonzalo-clion 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:
repo imports recorded ambiguous refs whose file imports the name now resolved gonzalo 1408 27 11 caliban 5803 68 45 web-viz-next 782 21 21 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_VERSIONis 6, so each view re-walks once on upgrade, and the store gains animportstable. (#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.
selftakes the enclosingimpl.On gonzalo's own source 1043 of 6732 method references now carry a type, 437 of which name a type the view defines;
impactoninsertwent from 60 symbols reached to 219, and its unattributed method edges fell from 368 to 274.insertis the seed that #248 could not move at all, because it is reached almost entirely throughstore.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_VERSIONis 5, so each view re-walks once on upgrade. (#251) -
callersandcalleesnow 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 adefinition_countalongside the list, plusdefined_inwhile 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/calleeskey, so they return an object rather than a bare array.impactalready reported the edges it declined; this brings the same honesty to the tools that do no resolution at all. (#249)
Fixed
-
unreferencedno 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 tobuild_rustand constants passed to functions.Such a reference is now recorded with its own kind and deliberately kept out of the call graph:
callers,calleesandtopstay call-only, because passing a function is not calling it.impactcounts them in a newvalue_edgesrather 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-zerovalue_edgesmeans 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_VERSIONis 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 toBeta'sgetinstead of merging with every othergetin 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 animplblock 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'spkg.Func()and Java'sFoo.bar()cannot be told from a receiver call at this level and stay unqualified rather than record a variable as a type.EXTRACTION_VERSIONis 3, so each view re-walks once on upgrade. (#248) -
Prebuilt macOS Apple Silicon binaries on every tagged release. A
v*tag now also buildsgonzalo-vX.Y.Z-aarch64-apple-darwin.tar.gz(plus a.sha256) and attaches it to the GitHub Release. The archive holdsgonzalo,gonzalo-mcpandgonzalo-parse-workertogether, 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-darwinis the only target built; the container image covers Linux. Seedocs/releasing.md. (#229)
Changed
-
ParseModeandWorkerSourceare#[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 designwith the citation. (#196) -
docs/releasing.mdno 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.0stopped 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_SIZEalso raises the decode limit for record RPCs, which HTTP does not do. Bounded and authenticated, so documented rather than closed. (#194)
Fixed
-
The packaged
gonzalobinary 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--versionran.gonzalo-mcpandgonzalo-parse-workerwere 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 nolibgit2-devinstalled.scripts/package-macos.shnow refuses to package a binary that links anything outside/usr/liband/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::Backendis documented as carrying the provider's message, but all five connectors reached it through reqwest'serror_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 syncexpands a leading~in its two store roots. Those are positional arguments and were the one place #211's expansion did not reach, sogonzalo 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-versioncannot 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--watchmeant once per file save. (#239) -
Remote reads report failures the way remote writes do.
ServerStore's read paths used reqwest'serror_for_status(), which discards the response body, so a403on 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;404still meansOk(None)onget/get_blob. (#195) -
A leading
~in a store root is expanded to$HOMEinstead of being taken literally.GONZALO_ROOT=~/.gonzaloin 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 coversGONZALO_ROOTand every--rootargument, which has the same exposure from a systemd unit or container spec, andstatusnow 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 indexsays which parse mode it is using instead of silently degrading. Crash isolation depends on findinggonzalo-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. Aparse:line now names the worker and how it was found; the fallback prints a warning listing every location searched; and--require-parse-workerturns a missing worker into a hard error for CI and container builds. Lookup also coversPATH, 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-workerno longer produces pre-upgrade extraction that the view records as current.EXTRACTION_VERSIONguarded 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::UniqueGlobalattributed a reference to the sole definition of that name in the view — including when the name was really a std or dependency method. In gonzalo,.chain(ours_obj.keys())ingonzalo-coreresolved tofn chain(), a test fixture ingonzalo-graph, a crategonzalo-coredoes not depend on.References now record the shape of the call site (
RefKind::{Free, Method}), and a cross-file method call resolves to the newResolution::ReceiverUnknownrather than guessing. Measured over gonzalo's own source: of the 388 cross-file method calls that used to resolveUniqueGlobal, 294 pointed at a different crate —push,filter,send,bytes,nextand friends. Same-file method calls still resolveLocal, and free and path calls (foo(),a::b::foo()) are unaffected.Effect on
impact(#207), same 122-file source both runs:seed before after build_rust185 126 assemble60 23 resolve_references_to6 6 The 24 provably-false
gonzalo-corenodes in thebuild_rustclosure are now 0. Dropped edges are reported asreceiver_unknown_edges, counted separately fromambiguous_edgesbecause the cause differs: not "too many candidates" but "cannot claim any candidate".RefKindis omitted from the serialized slice when free, so a file of plain calls keeps its existing content hash. -
impactno longer merges unrelated code through shared identifiers (#207). The closure walked the name-matched caller graph, so one hop into a name with several definitions absorbed every subgraph sharing that identifier. The walk now keys nodes on(name, defining path)and consults the resolver for every edge: anAmbiguousreference is counted and dropped rather than traversed.On the gonzalo view, seeded at
build_rust: 356 → 178 reached names, with 10 ambiguous edges reported rather than followed. Seeds that were already sharp are unchanged (resolve_references_to: 5 → 5).The result is now a report rather than a name list — every node carries the path defining it,
ambiguous_edgessays how many edges could not be attributed (so a non-zero count means the true set may be larger), andtruncatedreports a walk stopped by the new optionalmax_depth. The daemon's HTTP and gRPC transports keep their existing name-list shape and so get the precision fix without the report fields.Of the remaining 178, 17 are still provably false and trace to a single
UniqueGlobalover-attribution — std'sIterator::chainresolving to a same-named test fixture. That is a distinct defect, filed as #223. -
An incremental re-index now prunes paths a laxer run admitted (#209 follow-up). The filter added in #218 only applied to newly walked or changed files, so an existing view kept its vendored bundles forever: a bundle never changes, so it never appears in the git diff and was never reconsidered — and once a base commit is recorded there is no full walk to clean it up. Upgrading therefore fixed new views only, which is the case least in need of fixing.
The carried-forward set is now re-checked against the current rules, including
.gitignore— necessary becausedocs/guide/book/is build output excluded by ignore rules rather than by any directory-name rule, so a path-only prune left it behind.Re-indexing the existing
caliban-ai/calibanview: 17 162 symbols → 8 549, with vendored symbols going 8 618 → 0 and the largest file becomingcaliban/src/tui/events.rs(142) instead of a copy ofmermaid.min.js. -
An unknown
repo/view_idis now an error, not an empty result (#210). Every graph query returned[]withisError: falsewhen the selector named no indexed view, so a one-character typo inview_idwas indistinguishable from a symbol that genuinely is not there — an agent read it as "nothing calls this" and reported a wrong answer as fact.Service::viewnow fails withNotFound, and the MCP layer turns that into a tool error naming the unresolved selector and listing the views that do exist, so a caller can correct itself in one round trip. A real miss inside a real view still returns[], so the two cases are finally distinguishable.diffgets the same check on bothview_aandview_b. -
Calls inside Rust macro arguments are now recorded as references (#216). Macro arguments parse as a
token_treeof raw tokens rather than expressions, soassert_eq!(f(), 1)contained nocall_expressionand the call tofwas never seen. Because assertions are where much of a codebase is exercised, this silently removed a large share of the call graph:callers,callees,impactandtop by=fan_inall undercounted, andunreferencedreported live functions as dead.Re-indexing gonzalo itself, with an identical file set (1 852 symbols both runs), references go from 10 247 to 12 196 — +1 949 edges, +19.0%.
Language::from_extension, the symbol that exposed the bug, goes from 0 recorded references to 28.Detection is token-level: an identifier whose immediate next sibling is a parenthesised token tree. A nested macro has a
!between the two and is excluded. It is deliberately over- rather than under-inclusive — a tuple-struct pattern likeSome(_)reads as a call — which matches a graph that already records constructors and enum variants as calls.The other 17 grammars were audited for the same opaque-node hole. Only C/C++ has one: a
#definebody is a single opaquepreproc_argtoken with no child nodes to read. It is left in place and pinned by a test so the gap is discoverable rather than silent. -
The indexer no longer walks vendored bundles or gitignored build output (#209).
is_indexableskipped onlytarget,.git, and dotted components, so half of a real repo's graph was not that repo's code. Membership now lives in one place (IndexFilter), shared by the full walk and the git-incremental driver so they cannot disagree: dependency/output directories (node_modules,vendor,dist,build,site-packages,third_party) and generated files (*.min.js,*.min.css,*.bundle.js,*-lock.json) are dropped on both paths, and the full walk additionally honours.gitignore.Re-indexing
caliban-ai/calibandrops it from 16 986 symbols to 8 501 (-50.0%) with zero symbols frombook/**or any*.min.js; the largest file in the view is nowcaliban/src/tui/events.rs(142 symbols) rather than a 4 231-symbol copy ofmermaid.min.js. This also removes a reproducibility hole — indexing gitignored output made the graph depend on whether anyone had run a build.gonzalo indexnow reports what it excluded (ignored: N files, M dirs not descended), and--include <path>re-admits a vendored path that a built-in rule would drop.--includedeliberately cannot override.gitignore, so no flag can make a view irreproducible.
Added
-
EXTRACTION_VERSION, and a full walk when it changes (#223). The incremental driver carries unchanged slices forward untouched, so a parser improvement never reached files that did not change — an existing view stayed permanently half-upgraded.gonzalo indexnow records the extraction format alongside the view and rebuilds in full when it differs, which is what lets #216's and #223's parsing changes actually reach an established view. -
A guide chapter for the MCP server (#208) —
docs/guide/src/mcp.md, covering install → index → register → verify → keep fresh, a tool reference grouped by the question each tool answers, and the capability boundaries. It leads with the thing nothing in the repo stated: the server only reads,gonzalo indexwrites, so an unindexed setup answers every query forever with no indication why. It also records the traps found while wiring the server up for real —GONZALO_ROOTnot expanding~(#211),~/.cargo/binmissing from the non-interactive shells an MCP client spawns, and needing to reconnect the server to pick up a newly installed binary — plus a troubleshooting table keyed by symptom. -
viewsdiscovery tool and a view count instatus(#210).viewslists every indexed(repo, view_id)with its file count and the commit it was indexed at, which makes the server self-describing rather than dependent on out-of-band documentation; comparingbase_commitagainst the checkout's HEAD also surfaces a stale view, the quieter form of the same problem.statusnow reports how many views are indexed, so the natural health-check call actually detects a server pointed at an empty or wrong store. Therepo/view_idschema descriptions now say they must match an indexed view and point atviews. -
Aggregate code-graph queries (#214) — three MCP tools that answer questions about a view rather than about a symbol name the caller already has:
overview(file/symbol/reference counts, a breakdown by kind and language, and the largest files),top(rank byfan_in,fan_out, ordefinitions— adefinitionsscore above 1 marks an ambiguous name), andlist(enumerate symbols filtered bypath_prefix,kind, andname_contains). Backed by new defaultGraphStoremethods, so every store implementation inherits them. Results are bounded and reporttotal+truncatedrather than silently cutting. -
unreferenceddead-code candidates (#214) — a fourth aggregate tool listing symbols with no inbound reference, filtered by the samepath_prefix/kind/name_containsand bounded the same way.exclude_tests(default on) drops members of amod tests/mod testblock by line range and anything under atests/directory; on gonzalo itself that is the difference between 515 hits and 40. Deliberately errs toward silence — a reference from anywhere counts, including from tests and from the symbol itself. Its false positives are documented in the tool description, the rustdoc, and a pinned test: calls inside macro arguments are not recorded at all (assert_eq!(f(), 1)registers nothing), and a function passed as a value is a path expression rather than a call, so both look uncalled.
0.4.0 - 2026-08-01
The remote-parity & backend-qualification release. Deletion and blobs — the two
gaps that kept the daemon substrate behind fs/s3 — close, so a daemon-backed
consumer now gets the full Store + BlobStore surface. Alongside them, an HA
soak harness doubles as a conditional-write qualifier for S3 backends, and its
first finding disqualifies Garage outright.
Added
Store::delete(#183) — OCC-aware record deletion across every substrate and the daemon, propagated bySync. Local-only semantics; see ADR 0018. (#185)- Blobs over the daemon (#184) — the content-addressed
BlobStoreis exposed ongonzalo-server(HTTPGET|PUT|DELETE /v1/blobs/{hash},GET /v1/blobs, plus gRPC), andServerStoreimplementsBlobStoreover both transports, so a daemon-backed consumer gets the fullStore+BlobStoresurface. Blobs previously worked only onfs/s3. Adds theGONZALO_MAX_BLOB_SIZEdaemon knob (default 64 MiB). (#192)
Changed
- S3 backends are now qualified, and Garage is not among them (#52) — atomic
If-Matchis a hard requirement for any S3-compatible backend. Garage does not provide it: gonzalo's conditional-write conformance case expects exactly 1 of 8 concurrent racers to commit, and Garage let 8/8 through on v1.0.1 and 3–8 through non-deterministically on v2.1.0 — the signature of a check-then-set, not an atomic CAS. Deployments running gonzalo over Garage can silently lose concurrent writes. RustFS (Apache-2.0) is the qualified backend; MinIO passes the qualifier but is rejected on project sustainability. See ADR 0019. (#186, #205)
Testing: an HA soak harness for stateless gonzalod replicas over an
S3-compatible store — backend-agnostic, doubling as the conditional-write
qualifier above (#52, #186); cross-crate integration tests extracted into
gonzalo-integration-tests (#190, #191).
Project: a crates.io publishing pipeline for the workspace, triggered on v*
tags (#187, #189).
Docs: competitor capability inventories and parity-gap matrices for mem0, Zep,
and Letta under docs/evaluation/ (#193); ADR 0019 recording the S3 backend
qualification (#205).
0.3.0 - 2026-07-11
The hardening & language-breadth release. A broad correctness and robustness sweep — a 20-finding QA pass turned into fixes across the core merge/OCC model, every storage substrate, the daemon, the ticket connectors, and the knowledge/vector layer — lands alongside eight new code-graph grammars that take language coverage from nine to seventeen.
Added
- Language breadth for the code graph — grammars for Ruby, PHP, Bash
(#87), Kotlin (#87), Swift (#87), Lua (#87), Scala (#87), and
Elixir (#87). Elixir is homoiconic (
def/defp/defmacro/defmoduleparse as ordinarycallnodes), so it uses a value-basedwalk()dispatch on the call target's text rather than a node-kind mapping. Coverage is now 17 languages. (#126, #127, #128, #129, #130, #181)
Changed
- Ticket
RecordKeys are board-scoped — a board-scoped source folds its connection/board discriminator into the key, so the same issue imported from two boards no longer collides onto one thrashing record. Stored keys for board sources change shape and re-import on the next sync. (#159)
Fixed
- core — record-key encoding is now a reversible, injective percent-style
codec, so distinct keys can never collide onto one physical path (silent
cross-key overwrite / OCC bypass); append-only merge preserves blank and
legitimately-repeated committed lines instead of stripping/de-duping them, and
the
Derived/gc semantics are corrected. (#131, #133) - storage substrates — the git substrate locks the
putcritical section (no lost updates under concurrent writers) and detects non-fast-forward push rejection; filesystem writes fsync the temp file and parent directory for crash durability; S3 list-pagination terminates when the continuation token is absent; graph-sqlite view-db paths use the injective encoder. (#132, #134, #144, #145) - daemon — authorization runs before request deserialization, internal backend errors are returned opaquely (no path/bucket/SQLite leakage), the PUT record route validates its URL path against the body key, and the remote client surfaces daemon 403/413 responses instead of masking them as a decode error. (#146, #147)
- code graph — JS/TS arrow-function and function-expression bindings are
extracted, PHP method and static calls are recorded, and Swift/Kotlin
struct/enum/interfacedeclarations get their correctSymbolKind. (#136) - knowledge / vector / domain — a corrupt knowledge-bearing body surfaces an
ingest error (rather than silently not indexing) and removed records are
de-indexed; non-finite vectors score
0.0and rank deterministically; the domain codec rejects aBody::Blobinstead of misparsing its content hash. (#139, #149, #154) - cli —
get/ticket getexit non-zero (message on stderr) when a record is absent,indexadvances the persistent SQLite graph only after the manifest commits, and--gcis honored under--watch. (#152) - ticket connectors — Jira routes a
Canceledmove to a won't-do status rather than Done; a closed GitLab issue is terminal and non-terminal moves no longer report a false success; Linear fails a mutation that returnssuccess: false; the GitHub REST connector followsLinkpagination so all issues import, not just the first 100. (#138, #140, #141, #142)
Testing: de-flaked gonzalo-parse's hung-worker timeout test, whose 300 ms
budget false-timed-out the healthy recovery parse under heavy build load. (#178)
0.2.0 - 2026-07-06
The code-graph release. Gonzalo grows a full code-graph capability —
tree-sitter parsing across nine languages, content-addressed slices with
two-level keying, a persistent SQLite GraphStore, and structural queries over
both the daemon and an MCP server — alongside real semantic search (a local
Candle embedder feeding an approximate ANN index over per-kind-chunked
knowledge), namespace-scoped daemon auth, and correct content-aware 3-way merges
on both store-sync and git pull. Distributed as the gonzalod container image.
Added
- Code-graph capability (EPIC A–K): tree-sitter parsing into path-agnostic,
content-addressed slices with two-level keying (content+grammar hash for
storage, per-view manifest for resolution); a persistent
SqliteGraphStore; assembly-time name resolution; and structural queries — definitions, references, callers, callees, transitive impact, and cross-view diff — served over the daemon and a dedicated code-graph MCP server. Parsing is crash-isolated behind a worker-subprocessParserPool. (ADR 0012; #48, #50, #54, #56, #61, #64, #66, #70, #71, #74, #77, #88, #89, #90, #10, #30) - Language breadth for the code graph — a
Languagedispatch with grammars for Rust, Python, JavaScript/TypeScript/TSX, Go, Java, C#, C, and C++. (#79, #81, #83, #84, #85, #86) - CLI indexing (
gonzalo index): index a source tree into a code-graph view, git-diff-driven incremental re-sync, a--watchfile-watcher for live re-index, and opt-in mark-sweep GC of unreferenced slices. (#74, #93, #94, #100, #104) - Knowledge store (
gonzalo-knowledge): a "what do we know about X" surface composingStore+VectorIndex+Embedder, a vector⋈graph join (semantically similar and structurally near), and per-kind chunking so long records retrieve at turn/section granularity. (ADR 0011; #30, #29) - Real semantic vector search:
gonzalo-embed— a local CPU sentence embedder (Candle + all-MiniLM-L6-v2) — andHnswVectorIndex, an approximate ANN backend, behind the existingEmbedder/VectorIndextraits. (ADR 0013, ADR 0014; #97, #9) - Daemon substrate selection & health: env-driven
fs|s3store selection with a native S3BlobStore, and unauthenticated/healthz+/readyzprobes for k8s. (#62, #63) - Namespace-scoped daemon auth: a token→principal model with per-namespace read/write scoping enforced on both transports, plus unforgeable author stamping. (ADR 0015; #11)
gonzalodcontainer image — the release artifact, published on av*tag. (#51, #65)
Changed
- Store sync — true 3-way merge:
AncestryStoreretains each version's body by revision hash sosynccan merge divergent structured records against their real common ancestor instead of an empty base. (ADR 0016; #2) - Git pull — content-aware non-fast-forward merge: a diverged pull now
reconciles per-record through gonzalo's class-aware
merge()into a two-parent merge commit, surfacing unresolved records instead of erroring. (ADR 0017; #7) - S3 native conditional writes:
If-Match/If-None-Matchclose the optimistic-concurrency TOCTOU window in the S3 substrate. (#5)
Fixed
- CI: serialize GitHub Pages deploys with a concurrency group. (#107)
Internal
- ADRs 0010–0017 added (ticket capability layer, two-level code-graph keying, local embedder, ANN backend, namespace auth, stored-ancestry 3-way merge, content-aware non-FF pull).
- Docs: an mdBook guide publishing the ADR log, changelog, and a synthesized Guiding Principles & Invariants page; README status badges. (#103, #38)
0.1.0 - 2026-07-03
The initial development line — a generic, versioned, conflict-aware persistence layer for caliban, built milestone by milestone (M1–M6).
Added
- Record/Store core (M1):
gonzalo-core— one uniformRecordmodel and a genericStoretrait, with revisions, optimistic-concurrencyparenttracking,PutResult::Conflict, per-RecordKindmerge, and a feature-gated substrate conformance suite. No I/O in the core. (ADR 0002, ADR 0005, ADR 0006) - Filesystem substrate + domain + facade (M1):
gonzalo-store-fs(mirrors caliban's on-disk layout, the zero-dependency default),gonzalo-domain(typedMemoryTier/Topic/Session/Checkpointviews), and thegonzalofacade. (ADR 0004, ADR 0008, ADR 0009) - Git & S3 substrates + Sync (M2):
gonzalo-store-git(commit-per-write, fast-forward pull/push) andgonzalo-store-s3(S3-compatible object store), plus theSyncengine reusing the core conflict/merge machinery. (ADR 0004, ADR 0005) - Daemon + remote substrate (M3):
gonzalo-proto(one canonical schema),gonzalo-server(gonzalod) serving the store over both gRPC (tonic) and HTTP/JSON (axum) on one core service layer with optional bearer auth, andgonzalo-store-serveras the client substrate. (ADR 0007) - Vector layer (M4):
gonzalo-vector—Embedder+VectorIndextraits with a caller-delegating default embedder and an exact in-memory cosine index. (ADR 0008) - Code-graph layer (M5):
gonzalo-graph— a tree-sitter Rust symbol/ref index (build_rust) behind aGraphStoretrait. (ADR 0008) - Admin CLI (M6):
gonzalo-cli(gonzalo) —list,get,status,migrate,sync.
Internal
- Project: established
docs/adr/(MADR-lite) with the initial retrospective ADRs 0001–0009; added CI (fmt/clippy/build/test), a line-coverage gate, the Kanban label taxonomy, and board/triage automation.
Architecture Decision Records
- ADR 0001 · Record architecture decisions
- ADR 0002 · Uniform
Record+ genericStorecore (Approach A) - ADR 0003 · License: AGPL-3.0-only
- ADR 0004 · Pluggable storage substrates behind one
Storetrait - ADR 0005 · Optimistic concurrency with explicit conflict surfacing
- ADR 0006 · Shared substrate conformance suite
- ADR 0007 · Dual-transport daemon: gRPC + HTTP/JSON over one schema
- ADR 0008 · Domain, vector, and graph as capability layers over core
- ADR 0009 · Workspace layout and single-facade public surface
- ADR 0010 · Ticket systems as a normalized work-item capability layer
- ADR 0011 · Knowledge store over the capability layers
- ADR 0012 · Two-level keying for the code graph
- ADR 0013 · Local Candle embedder for real semantic embeddings
- ADR 0014 · Approximate vector index backend (hnsw_rs)
- ADR 0015 · Namespace-scoped daemon auth
- ADR 0016 · 3-way merge with content-addressed stored ancestry
- ADR 0017 · Non-fast-forward git pull via content-aware merge
- ADR 0018 · Record deletion and its sync semantics
- ADR 0019 · Qualified S3 backend for HA: RustFS
- ADR 0020 · Rust-native deliverables; the daemon is the non-Rust boundary
- ADR 0021 · Replicated deletion with tombstones
- ADR 0022 · Fleet access-control records as a capability layer
- ADR 0023 · Channel configuration fields, and workspaces instead of repos
- ADR 0024 · Blob garbage collection, and tombstones pin their blobs
- ADR 0025 · Sibling tombstone markers in the S3 layout
- ADR 0026 · A grouped facade surface
- ADR 0027 · A durable vector index
- ADR 0028 · A grace period for blob garbage collection
- ADR 0029 · Manifest tombstones retain their body, and
undeleterestores it - ADR 0030 · A create over a manifest tombstone is refused, and
gonzalo purgediscards one deliberately - ADR 0031 · The daemon's physical-removal surfaces require admin, and refuse to remove a live record
ADR 0001 · Record architecture decisions
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo began as a design spec (docs/superpowers/specs/) and a set of
per-milestone plans, with the significant decisions captured there. Now that
milestones M1–M6 are implemented, those decisions are spread across long design
documents and commit history — hard to consult and easy to let drift from the
code. The sibling repos caliban and prospero both keep an ADR log; gonzalo had
none.
Decision
We will keep an Architecture Decision Record log under docs/adr/, in
MADR-lite format, matching caliban (and prospero once
caliban-ai/prospero#30
lands). Each architecturally significant, hard-to-reverse, or future-constraining
decision gets one append-only record with Context, Decision, and
Consequences. Superseded decisions are marked and linked, never deleted.
This first set of ADRs (0002–0009) is retrospective: it documents decisions already embodied in the M1–M6 code, so the rationale is captured before it is lost. Decisions from here on are recorded as they are made.
Consequences
- Positive: One durable, greppable home for "why"; new contributors and sibling-repo readers meet one consistent format across caliban / gonzalo / prospero; design rationale stops drifting from the code.
- Negative: An ongoing discipline cost — a significant decision now means writing an ADR, not just code. Retrospective ADRs also risk rationalizing after the fact rather than capturing the live trade-off.
- Revisit if: the MADR-lite format diverges from the agreed cross-sibling standard (see caliban-ai/prospero#30), or the log proves too heavyweight for the team's cadence.
ADR 0002 · Uniform Record + generic Store core (Approach A)
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo must persist several caliban data types — memory tiers, auto-memory topics, sessions, checkpoints — across several substrates (filesystem, git, S3, remote daemon), with versioning, optimistic concurrency, conflict surfacing, and sync. Three shapes were considered:
- (A) One uniform persisted unit (
Record) and a single genericStoretrait; caliban's types become typed views layered on top. - (B) A typed store per domain type — which re-implements versioning / conflict / sync for every type × substrate combination (combinatorial duplication).
- (C) A schemaless JSON-document store — which discards the type safety that motivates writing the system in Rust.
Decision
Adopt Approach A. gonzalo-core defines one Record (key, kind, revision,
parent, body, meta, links) and a generic Store trait over it. Substrates
implement only the generic Store and never know about caliban's types.
Caliban's types live as typed views in gonzalo-domain, mapped to/from Record
via serde.
The hard parts — versioning, optimistic concurrency, conflict surfacing (ADR 0005), and sync — are written once in the core, independent of both substrate and domain type.
Consequences
- Positive: Versioning / conflict / sync logic exists once, not per
type × substrate. New substrates and new domain types are independent axes —
adding one does not touch the other. The vector and graph layers key off the
same
RecordKey, so their queries return first-class records. - Negative: Everything funnels through one
Recordshape; a domain type that fits the model poorly must still be marshalled into it. An extra mapping layer (domain view ↔Record) sits between caliban and storage. - Revisit if: a domain type cannot be reasonably expressed as a
Record, or the generic core blocks a substrate-specific optimization that materially matters.
ADR 0003 · License: AGPL-3.0-only
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo needs a license. It is a persistence layer for caliban, which is itself
licensed AGPL-3.0-only, and it is intended to be deployable as a network
daemon (gonzalod) — so network-use copyleft is directly relevant rather than
incidental.
Decision
License gonzalo under AGPL-3.0-only, matching caliban. The LICENSE file
carries the full text; license = "AGPL-3.0-only" is set in the workspace
package metadata.
Consequences
- Positive: Consistent with caliban (the primary consumer); strong copyleft including the AGPL network-use provision, which fits a shareable daemon; one license story across the sibling repos.
- Negative: AGPL deters some commercial/proprietary adopters and cannot be linked into closed-source software; operators who modify and serve the daemon take on the network-copyleft obligation.
- Revisit if: a relicensing need arises (e.g. broader ecosystem adoption), which would require consent from all contributors.
ADR 0004 · Pluggable storage substrates behind one Store trait
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo's value proposition is taking caliban's local-first state shared "by configuration, not code." That requires several backends — a local filesystem (today's behavior), git (auditable shared history), S3 (large cheap blobs), and a remote daemon (central server) — without each backend re-deriving the core semantics, and without forcing every build to pull heavy dependencies (git2, aws-sdk, tonic).
Decision
Each backend is a separate crate implementing gonzalo-core::Store:
gonzalo-store-fs[fs]— filesystem, mirrors caliban's on-disk layout; the zero-dependency reference/default.gonzalo-store-git[git]— commit-per-write, fast-forward pull/push.gonzalo-store-s3[s3]— S3-compatible object store.gonzalo-store-server[remote]— proxies to a remote daemon over HTTP or gRPC (client side of ADR 0007).
Heavy dependencies live only in their owning substrate crate and are selected through facade Cargo features (ADR 0009), so a filesystem-only build stays lean. Which substrate backs caliban is configuration, not API.
Consequences
- Positive: Going from local to git / S3 / daemon is a config change, not a
code change. A default build pays for nothing but
fs. New backends slot in by implementing one trait and passing the conformance suite (ADR 0006). - Negative: The
Storetrait is a lowest-common-denominator surface — substrate-specific capabilities must fit it or be abstracted away. Every substrate carries the cost of conforming to the full semantics (versioning, conflicts) even where its native model differs. - Revisit if: a needed backend cannot satisfy the
Storecontract, or the trait starts accreting substrate-specific escape hatches.
ADR 0005 · Optimistic concurrency with explicit conflict surfacing
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo's premise is that multiple systems and contributors share state. Concurrent edits to the same record are therefore expected, and caliban's durable memory must never silently lose a contributor's write. We need a concurrency model that works identically for local writes and cross-replica sync, across every substrate.
Decision
Use optimistic concurrency with explicit, typed conflict surfacing:
- Writes are conditional:
put(record, expected_parent_rev). If the stored revision no longer matches the expected parent, the store returnsPutResult::Conflictrather than overwriting. - The core ships merge strategies keyed by
RecordKind: append-only kinds (auto-memory topics, session transcripts) auto-merge by union/concatenation; structured kinds attempt a field-level 3-way merge against the base; anything ambiguous is surfaced to the caller and togonzalo-clifor resolution. Sync(pull → detect divergence → merge → push) reuses this exact machinery, so reconciliation and local writes share one code path.Conflictis a recoverable result variant, not a generic error.
Consequences
- Positive: Concurrent edits are never silently lost — the core invariant. One conflict/merge implementation serves both local writes and sync. Callers get a typed, recoverable outcome they must handle, not a stringly error.
- Negative: Every writer must handle a
Conflictoutcome — more caller complexity than last-write-wins. Concurrency is optimistic, so a high-contention record can see repeated retry/merge cycles. - Revisit if: a workload needs last-write-wins or server-side locking, or the per-kind merge strategies prove insufficient for a real conflict pattern.
ADR 0006 · Shared substrate conformance suite
- Status: accepted
- Date: 2026-06-13
Context
Four substrates (fs, git, S3, server) implement the same Store contract, with
subtle shared semantics: revision monotonicity, conditional-put conflict
behavior, body round-tripping, key listing. If each substrate were tested only
in isolation, they would inevitably drift in behavior and caliban could not
treat them interchangeably.
Decision
gonzalo-core ships one shared conformance test suite that every Store
implementation must pass. Each substrate crate runs the suite against its own
backend (fs, git, S3, the daemon-backed server substrate). The suite is the
executable definition of what "being a Store" means.
Consequences
- Positive: All substrates are held to one behavioral spec, so they are genuinely interchangeable. A new substrate's correctness bar is simply "pass the suite." Semantics live in one place rather than scattered across per-substrate tests.
- Negative: The suite is a coupling point — tightening it can require work across every substrate at once. Backends that need external services (S3, the daemon) require test infrastructure (wiremock / testcontainers) to run it.
- Revisit if: a legitimate substrate cannot satisfy a suite assertion, and the contract needs capability tiers rather than one flat spec.
ADR 0007 · Dual-transport daemon: gRPC + HTTP/JSON over one schema
- Status: accepted
- Date: 2026-06-13
Context
The optional daemon (gonzalod) lets non-Rust tools and remote systems share a
Gonzalo store. Two audiences pull in opposite directions: Rust clients and
streaming large transfers want strongly-typed gRPC; ad-hoc tooling and
humans want a curl-able HTTP/JSON API. Maintaining two independent
implementations of the same surface would let them drift apart.
Decision
gonzalo-server exposes both transports — a tonic gRPC service and an
axum HTTP/JSON service — over one shared core service layer.
gonzalo-proto holds the single canonical schema both derive from: protobuf for
gRPC, serde types for HTTP/JSON. Payloads are JSON-encoded gonzalo-core
records carried as bytes, so both transports share one serialization and stay in
lockstep. The daemon supports optional bearer-token auth with namespace-scoped
checks. gonzalo-store-server is the client side, speaking either transport.
Consequences
- Positive: Operators pick the transport that fits — Rust clients get typed gRPC, everyone else gets curl. One service layer and one schema mean the two transports cannot drift. Auth is one concern handled at one layer.
- Negative: Two server stacks (tonic + axum) to build and keep running. Carrying JSON-over-bytes inside protobuf forgoes some of gRPC's native typed payloads for the sake of a single shared serialization.
- Revisit if: one transport goes effectively unused (drop it), or performance demands native protobuf payloads instead of JSON-over-bytes.
ADR 0008 · Domain, vector, and graph as capability layers over core
- Status: accepted
- Date: 2026-06-13
Context
Caliban needs more than key-value persistence: typed access to its own data, semantic (vector) retrieval, and structural (code-graph) queries. These could have been built into the core or into each substrate, but that would entangle retrieval concerns with storage and force every substrate to reimplement them.
Decision
Keep gonzalo-core storage-only and add capabilities as layers over it,
each keyed by the shared RecordKey:
gonzalo-domain— typed views (MemoryTier,Topic,Session,Checkpoint) mapped to/fromRecordvia serde.gonzalo-vector—Embedder+VectorIndextraits. Embedding generation delegates to the caller by default (the core stays provider-agnostic). What shipped in M4 is an exact in-memory cosine index; approximate/external indexes (HNSW, sqlite-vec, Qdrant) anticipated by the design spec remain future, feature-gated impls.gonzalo-graph— atree-sitter-based Rust code graph (build_rust) and aGraphStoretrait over symbols / files / references / edges.
Because every layer keys off RecordKey, semantic and structural queries return
first-class records, and the layers compose (vector ⋈ graph) by shared key.
Consequences
- Positive: Storage and retrieval stay decoupled — substrates never know
about vectors or graphs. The shared
RecordKeymakes the layers composable and keeps query results first-class. Provider-agnostic embedding keeps the core free of model dependencies. - Negative: The shipped exact in-memory vector index does not scale to large corpora; production-scale retrieval will need the not-yet-built approximate indexes. Two retrieval layers add API surface beyond plain persistence.
- Revisit if: corpus size outgrows the exact in-memory index (prioritize a real ANN impl), or a capability needs core/substrate support that a pure layer cannot provide.
ADR 0009 · Workspace layout and single-facade public surface
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo is 12 crates (core; the fs/git/s3/server substrates; proto; server; domain; vector; graph; cli; and the facade). Caliban should not have to depend on, and feature-wrangle, all of them. We also want heavy dependencies (git2, aws-sdk, tonic) to be opt-in so a default consumer stays lean.
Decision
Use a single Cargo workspace (edition 2024, rust-version 1.95), with every
crate prefixed gonzalo- except the facade. The gonzalo facade is a thin
re-export crate giving caliban one dependency and a curated public surface;
substrates and capability layers are selected via the facade's Cargo features
(fs, git, s3, remote, vector, graph). Internal crate versions are
centralized in [workspace.dependencies], and unsafe_code is forbidden
workspace-wide. The two binaries are gonzalod (daemon) and gonzalo (CLI).
Consequences
- Positive: Caliban depends on one crate and turns capabilities on by feature; default builds avoid the heavy deps. Single-responsibility crates keep compile units and ownership clear. Centralized workspace deps and lints keep the tree consistent.
- Negative: Twelve crates is real overhead — a change touching the core can ripple through the workspace, and the facade must be kept in sync with what it re-exports. Feature combinations need testing to ensure each builds.
- Revisit if: the crate count becomes a maintenance burden out of proportion to the isolation it buys, or feature combinations prove untestable.
ADR 0010 · Ticket systems as a normalized work-item capability layer
- Status: accepted
- Date: 2026-06-14
Context
Caliban and the sibling repos increasingly reason over tracked work — issues,
Kanban cards, ADR-linked tasks — that today lives entirely outside Gonzalo. We
want that work to be a first-class Record: versioned, synced, conflict-merged,
and composable with the vector and graph layers the same way memory and sessions
are. Gonzalo has no such abstraction today (no Ticket kind, no source trait).
The hazard is the one ADR 0004 flags for Store: a lowest-common-denominator
trait that flattens real differences, or one that accretes provider-specific
escape hatches until it is not an abstraction. Ticket platforms diverge more than
storage backends do, so this risk is acute. We surveyed nine platforms chosen to
cover distinct data-model archetypes, not for breadth's sake:
- GitHub — open/closed + reason; Projects v2 status is a per-project field.
- Jira — custom workflow →
statusCategory; transition-gated writes; ADF body. - Linear — typed states (backlog/started/completed/canceled); direct set.
- GitLab — tier-dependent: free = open/closed +
workflow::*scoped labels; Premium = native categorized Status; custom fields GA in 18.0. - Asana — no intrinsic state (
completedbool / section / enum field); multi-homed across projects; single assignee; plaintext/HTML body. - Azure DevOps — work-item type (process template) drives fields and states; states grouped into categories; any-to-any transitions by default.
- Bugzilla — two-dimensional state:
status×resolution(FIXED / WONTFIX / DUPLICATE / INVALID …). - Monday / Airtable — fully schemaless: title, status, assignee are all user-named columns/fields.
- Zendesk / ServiceNow — support/ITSM: distinct requester/assignee/submitter,
a
pendingstate, cross-type links (incident→problem→change), SLA events.
Two facts fell out of the survey. First, a categorized status model is shared by six of the nine (Jira, Linear, GitLab-Premium, Azure DevOps, ClickUp, Shortcut) — so a normalized state category is a real spine, not a forced fit. Second, the signal that carries status is configured per connection, not fixed per provider (GitLab free vs Premium; Asana completed vs section vs field).
Decision
Model tickets as a capability layer over core (ADR 0008), not a new core
concept (ADR 0002). The only core change is registering two new RecordKind
variants (Ticket, TicketEvent) and their merge classes — the same minimal
touch every domain kind already requires; no new core traits or types enter.
- Record shape. New
RecordKinds — a ticket and an append-only ticket-event/comment stream — with a typedTicketview ingonzalo-domain, mapped to/fromRecordvia serde, exactly asMemoryTier/Sessionare. - Normalized canonical model + lossless raw.
Ticketcarries anitem_type; aState { category, resolution, raw_name, raw_id }(category is the cross-platform spine,resolutionthe second axis Bugzilla/Jira need, raw round- trips); actor roles (Requester | Assignee | Submitter | Follower); normalized common fields (title, body, priority, labels); many-to-manycontainers(Asana multi-home, multi-board); typedlinks(blocks/relates/ parent/duplicate →RecordKeyor external ref); aBody { markdown, raw, format: Markdown|Adf|Html|PlainText }; and a boundedfieldsmap for everything else.StateCategoryincludes aPending/blocked member. TicketSourcetrait is the provider boundary — the ticket analogue ofEmbedder, keeping Gonzalo provider-agnostic about where tickets come from.- Per-connection mapping policy. Because state and fields are instance-
configured, each connection carries a
FieldMapping/StateMapping(the generalization of a fixed per-provider schema) that resolves canonical fields and the normalized state category from the configured signal — intrinsic state, scoped label, native status, section, or custom field. - Capability negotiation, not escape hatches. A
capabilities()descriptor (push,transitions_required,custom_fields,single_assignee,hierarchy,relations,comments) replacesif provider == …branches; available writes may be dynamic (auth/workflow-dependent). - Reuse concurrency + merge (ADR 0005). Ticket state is a structured kind → field-level 3-way merge; the event/comment stream is append-only → union-merge.
- Opaque incremental
Cursorowned by each source (timestamp / JQL bound / GraphQL cursor / event sync token) — not Gonzalo'sRevision. - Read-only import first; capability-gated write-back second.
fetch_changedis uniform across every platform and tier and already delivers the composition value;set_state(category)(which the source resolves to a Jira transition, a GitLab label swap, or an Asana section move) is opt-in phase 2. - Conformance keyed on policy variants (ADR 0006), not just providers: "GitLab-free (scoped-label)" and "GitLab-Premium (native status)" are distinct fixture sets, run against recorded fixtures like the S3/daemon substrates.
- Scope boundary. Schemaless DB tools (Monday/Airtable) are supportable via
FieldMappingbut are not design drivers; alerting/error-aggregation tools (PagerDuty/Sentry) are out of scope — they are not human-authored tickets. - Composition. Because tickets key off
RecordKey, ticket ⋈ graph (which symbols/files a ticket touches) and ticket ⋈ vector (semantic search over bodies) fall out for free, returning first-class records (ADR 0008).
Consequences
- Positive: Tracked work becomes a first-class
Record— versioned, synced, conflict-aware — with only the minimalRecordKindregistration every domain kind needs. The normalized model is validated across nine platforms and nine archetypes, so adding a tenth (Shortcut, ClickUp, Azure DevOps variants) is "implement one trait + declare a mapping policy + capabilities + fixtures." Read-only-first confines all per-instance write risk to an opt-in phase. - Negative: The canonical model is wide (item_type, two-axis state, actor
roles, many-to-many containers, raw passthrough) — more surface than a
GitHub-issue clone. Per-connection
FieldMappingis configuration users must get right, especially for schemaless tools. TwoRecordKinds per ticket (state + events) is more mapping than a single document. - Revisit if: a platform cannot be expressed even with
FieldMapping+fields(revisit the canonical shape); two-way sync conflict between Gonzalo's merge and a remote's authoritative state proves intractable (reconsider write- back); or the layer needs core/substrate support a pure layer cannot provide.
ADR 0011 · Knowledge store over the capability layers
- Status: accepted
- Date: 2026-06-14
- Source: #16
Context
Caliban needs a "what do we know about X" retrieval surface that spans its
durable record kinds — memory tiers, auto-memory topics, sessions, and now
tickets (ADR 0010) — backed by semantic (vector) and, later, structural (graph)
retrieval. The ingredients already exist: gonzalo-vector (Embedder +
VectorIndex), the gonzalo-domain typed views, and gonzalo-graph — all keyed
by the shared RecordKey (ADR 0008). What is missing is a surface that composes
them. Without one, every caller re-wires embed → index → fetch by hand, and there
is no single place that decides which record kinds are knowledge-bearing or how
their text is extracted.
ADR 0008 also flagged that the shipped exact in-memory VectorIndex does not
scale; a real corpus needs a production index. That decision has been deferred
until there was a consumer for it — the knowledge store is that consumer.
Decision
Add a gonzalo-knowledge capability crate (facade feature knowledge) that
composes the existing layers; gonzalo-core does not change.
- Surface.
KnowledgeStore<S: Store, V: VectorIndex, E: Embedder>with:ingest(key)— fetch theRecord, extract its knowledge text, embed it viaE, andupsertit intoVunder the sameRecordKey.query(text, k, filter)— embed the query,VectorIndex::queryfor the top-kkeys, then resolve them throughSto first-class records.- Results are
Hit { record, score }— first-class records, per ADR 0008's principle that retrieval returns records, not bare ids.
- Knowledge-bearing kinds (extracted via
gonzalo-domainviews):MemoryTier(content),Topic(slug + bullets),Session(name + turn text),Ticket(title + body + labels),TicketEvent(body).Checkpointis not knowledge-bearing. This mapping lives in one function,knowledge_text. - Chunking. Phase 1 embeds one document per record. Per-kind chunking (a session by turn, a long tier by section) is a future refinement behind the same surface.
- First production index. Adopt sqlite-vec as the zero-infra default
(a single file, no daemon — matching the
fs-default ethos), with Qdrant as the daemon-side option. Both slot behind the existingVectorIndextrait, feature-gated per ADR 0009. These impls are follow-ups; the exact in-memory index remains the default until they land. - Composition. Because hits key off
RecordKey,vector ⋈ graphis a query-time intersection — rank by similarity, then filter/expand by a code-graph neighborhood addressed by the same key. The crate ships the vector path now; the graph-backed filter lands with aGraphStorejoin.
Consequences
- Positive: One retrieval surface instead of hand-wired embed/index/fetch;
composes by
RecordKey, so tickets, memory, and sessions are searched uniformly; embedding stays provider-agnostic (delegated toE); zero core change. - Negative:
KnowledgeStoreis generic over three type parameters (S,V,E) — some signature heft. The in-memory index still caps scale until sqlite-vec lands.knowledge_textcouplesgonzalo-knowledgeto the domain view shapes (a new knowledge-bearing kind must be added there). - Revisit if: a knowledge-bearing kind needs chunking the one-document model can't express; or the generic composition blocks a cross-store optimization a unified impl would allow.
ADR 0012 · Two-level keying for the code graph
- Status: accepted
- Date: 2026-07-03
- Source:
docs/superpowers/specs/2026-07-03-code-graph-capability-design.md
Context
gonzalo-graph parses source with tree-sitter into a CodeGraph of Symbols
and name-based References (ADR 0008 places it as a capability layer over core).
To serve agents working in isolated git worktrees — including the product case
where Caliban runs a fleet of sub-agents, each in its own worktree, on the same
repo — the graph must be persisted, kept fresh, and queried per agent.
The naive design keys a file's graph by (repo, path) with last-writer-wins.
That is wrong the moment two worktrees edit the same path differently: both
resolve to one key, the second write clobbers the first, and an agent then
queries a graph describing another worktree's code — actively lying about the
tree it is editing. "They merge later" does not help; during the divergence
window each agent needs a graph matching its own tree now.
The error is conflating two distinct identities:
- Storage identity — what dedups. A file's graph slice is a pure function of its content and the grammar version.
- Query identity — what an agent resolves against. This is per-worktree (per view), because divergent worktrees are the whole point.
This is exactly git's blob/tree split, which fits because these are literally git worktrees. A related question — which engine backs the queryable graph (in-memory, SQLite, Cozo, …) — must not be allowed to leak into the durable source of truth.
Considered options
- Key everything
(repo, path), last-writer-wins. Rejected: clobbers divergent worktrees (above). - Key everything
(repo, worktree, path), no indirection. Correct, but every worktree stores a full copy of every slice, including unchanged files — N× storage across a fleet. - Two-level: content-addressed slices + per-view manifests. Correctness and dedup. Chosen.
Decision
We will key the code graph at two levels, and never by (repo, path).
- Storage layer — content-addressed, deduped slices. A file's graph slice
(the symbols it defines, the references it emits) is stored keyed by
(file_content_hash, grammar_version), write-if-absent. Byte-identical files across worktrees share one slice. This is whatBody::Blob(content-addressed record bodies) is for. - Identity layer — per-view manifests. Each workspace view (a worktree, or
in the product case an arbitrary target-repo checkout) owns a manifest
(repo, view_id) → { path → content_hash }. An agent resolves a path through its manifest to its slice. The resolvable key is per-view; storage stays content-addressed. Neither layer is keyed(repo, path). - Slices are path-agnostic.
SymbolandReferencecarry line ranges only, not afilefield; the path is supplied by the manifest at assembly time. A rename (byte-identical content) is a manifest repoint, not a reparse — path is identity, not content. - Resolution happens at assembly time, never in a stored slice. Slices are
stored raw, name-based, and unresolved; name resolution runs over the slice set
a manifest assembles, per query. Resolution must tolerate missing targets —
a file absent from a view yields honest dangling references, which is the truth
of that tree. (
gonzalo-graph's existing "references are unresolved" state is therefore the correct layering, not merely unfinished.) - Sync is set-reconciling, not append-only. A view's manifest must equal the
set of files present in that view. The driver is
git diff/git status, which reports deletes (D) as first-class alongside adds/modifies (A/M) and reconciles the manifest to the tree by construction. A write-only file-watcher is insufficient: it leaves ghost paths resolving to dead content. Any watcher path must handle unlink events plus a periodic full reconcile. - Storage-engine invariant. No query engine (in-memory, SQLite, Cozo, …) ever
sits under the
Storesubstrate (the durable, versioned, conflict-aware source of truth). Engines back only the derived, regenerable index layers (GraphStore, and optionallyVectorIndex), which are swappable behind their traits. Content-addressed slices are aBody::Blob/ KV concern, distinct from the assembled queryable graph the engine backs.
Consequences
- Positive: Divergent worktrees each query a graph matching their own tree — correctness under a fleet — while byte-identical files share one slice (dedup, no N× blow-up). Renames are free. Deletion is a per-view manifest edit with no "whose delete wins" ambiguity, and slices reclaim through one GC path (an edit orphans an old-content hash exactly as a delete does). Conflicts nearly vanish: slices are write-if-absent (same hash ⇒ same bytes), manifests are single-writer-per-view. The engine choice stays reversible behind a trait.
- Negative: Two-level indirection is more moving parts than one keyed table:
a content-addressed blob store (
Body::Blob, pulled forward from its reserved milestone), per-view manifest records with aMergeClass::Derivedarm, GC (refcount or mark-sweep over live manifests), and assembly-time resolution that must handle dangling references. Resolution cost moves from write time to query time. - Revisit if: views stop being git worktrees (the blob/tree analogy weakens); or single-writer-per-view no longer holds (concurrent writers to one manifest would need real merge, not last-writer-wins on the rare race); or profiling shows assembly-time resolution is too costly to run per query and a resolved cache must be introduced (without baking resolution back into stored slices).
ADR 0013 · Local Candle embedder for real semantic embeddings
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-local-candle-embedder-design.md
Context
gonzalo-vector defines an Embedder trait (ADR 0008) but ships only a
bag-of-words test embedder, so the knowledge store's semantic search (ADR 0011)
has no real semantics. We need a genuine sentence embedder, subject to #40's
standing constraint: FOSS, local-only — no cloud embedding APIs, no keys in
the default path.
The decisions in play were: which model (all-MiniLM-L6-v2 vs bge-small vs
gte-small — weighing MTEB quality, parameter count, and license compatibility
with this AGPL-3.0 project per ADR 0003); how weights are acquired
(download-on-first-use vs user-provided path vs bundling ~90MB in the crate);
and where the implementation lives (a heavy ML dependency set behind a feature
in gonzalo-vector, or isolated in a dedicated crate).
Decision
We will add a new crate gonzalo-embed implementing the gonzalo-vector
Embedder trait with a local CPU embedder built on Candle
(candle-transformers BERT + tokenizers).
- Model:
sentence-transformers/all-MiniLM-L6-v2— Apache-2.0 (redistributable, compatible with AGPL-3.0), 384-dim. - Acquisition: download-on-first-use from HuggingFace via
hf-hub(anonymous, no key, cached), with anEmbedderConfig.model_pathoverride for fully-offline use. Downloading weights is not a cloud inference API and needs no key, so #40's constraint holds. - Pipeline: tokenize → BERT forward → masked mean-pool → L2-normalize →
384-dim unit vector; the sync CPU forward runs inside
spawn_blocking. - Boundary: the ML dependencies live only in
gonzalo-embed;gonzalo-vectorstays dependency-light. Matches the crate-per-substrate idiom (gonzalo-store-fs/-s3).
Errors map to CoreError::Backend. Scope is single-text embed on CPU; batch
and GPU are explicitly out of scope.
Consequences
- Positive: real semantic retrieval behind the existing trait — no caller
change (the knowledge store just gets a better
Embedder). ML weight is quarantined in one opt-in crate. License is cleanly compatible and local-only is preserved. - Negative: first use downloads ~90MB (unless
model_pathis set); Candle + tokenizers + hf-hub are a heavy dependency set for that crate; CPU inference is slower than a hosted API. Real-model tests must be#[ignore]d so CI does not download weights. - Revisit if: we need batch/GPU throughput, a different model (quality or
size), or the download-on-first-use default proves problematic in air-gapped
deployments (promote
model_path/a bundled option to the default).
ADR 0014 · Approximate vector index backend (hnsw_rs)
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-approximate-vector-index-design.md
Context
gonzalo-vector shipped only MemoryVectorIndex, an exact brute-force cosine
kNN (O(n) per query). The VectorIndex trait (ADR 0008) was designed so an
approximate backend could be added without breaking callers, and the gonzalo
design (§12) left the choice of in-process ANN crate — usearch vs hnsw_rs —
as an open question to benchmark.
Two forces decided it. First, deletion: the trait requires remove, and the
knowledge store (#29) calls it to evict orphaned chunks on re-ingest — usearch
has native removal, hnsw_rs has none. Second, build footprint: usearch
bundles a C++ library (C++ toolchain, longer builds; its unsafe FFI is internal
to the dependency), while hnsw_rs is pure Rust. CI builds with
--all-features --all-targets, so any feature-gated C++ dependency would still be
compiled in CI.
Decision
We will add HnswVectorIndex to gonzalo-vector, backed by hnsw_rs
(pure Rust), behind a non-default hnsw feature. MemoryVectorIndex remains
the exact default.
- Deletion —
hnsw_rscannot delete or update in place, so we own a tombstone-and-rebuild layer:removeand re-upserttombstone the old id; queries skip tombstoned ids; the graph is rebuilt from live entries oncetombstones > live && tombstones > 64. - Keying — a
RecordKey ↔ usizebimap bridges the trait's keys to hnsw's integer ids; vectors are retained for exact re-scoring and rebuilds. - Filtering/scoring —
queryover-fetches from hnsw, drops tombstoned ids, applies theKeyPrefixfilter, and recomputes exact cosine on the survivors (soMatch.scoreis truthful); it may return fewer thank. - Benchmark — a head-to-head
hnsw_rsvsusearchbenchmark (recall@10, insert/query latency) lives in a crate excluded from the workspace, so usearch's C++ never reaches CI. Results are documented.
Consequences
- Positive: sub-linear approximate search behind the unchanged trait; a clean
pure-Rust CI build with no C++ toolchain;
MemoryVectorIndexstill available for exact needs and small indexes. - Negative: we own the tombstone/rebuild deletion layer that
usearchwould have provided natively; approximate results meanquerycan miss a true neighbor or return<k; graph memory grows with churn until a rebuild. - Revisit if: deletion churn makes rebuilds too frequent, we need on-disk or
distributed scale, or recall proves insufficient — at which point
usearch(native delete) or a served index backend should be reconsidered. (The on-disk case is answered by ADR 0027.)
ADR 0015 · Namespace-scoped daemon auth
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-namespace-scoped-daemon-auth-design.md - Amended by ADR 0031,
which raises the raw replication write from
writeon the namespace to admin. The scoping model itself is unchanged; one route moved between its tiers.
Context
The daemon (gonzalo-server) shipped with a single shared bearer token
(GONZALO_TOKEN): all-or-nothing authentication, no principals, no scoping. The
design (spec §9) always intended token auth with a RecordKey-namespace-scoped
permission check, "designed so finer-grained policy can slot in later without
touching the core." This ADR records that step.
Decisions in play: how principals are configured (an env-encoded blob vs a
readable file); how the new model stays backward-compatible with the single
token; whether operations without a natural namespace (graph queries by
repo/view, ticket sync) are scoped or merely authenticated; and whether the
daemon trusts the client-supplied Meta.author or stamps it from the
authenticated principal.
Decision
We will replace the single-token check with a principal model in a pure
auth.rs: a token maps to a Principal with per-namespace read/write lists
("*" = any namespace).
- Config —
GONZALO_AUTH_FILEpoints to a TOML file of principals; if unset butGONZALO_TOKENis set, that becomes a single admin principal (back-compat preserved); if neither is set, auth is disabled (open), matching local mode. - Enforcement — both transports authenticate at the edge (token → principal,
else 401/unauthenticated) and authorize in each handler, where the target
namespace is known:
get/listneedread,put/ticket_syncneedwrite, graph queries needreadon thereponamespace (which is the manifest's namespace).listwith no namespace requires an admin (readon*). Denied → 403/permission_denied. - Non-CRUD ops are scoped, not just authenticated — graph queries map to the
repo namespace and ticket sync to the
ticketsnamespace, both natural keys. - Author is stamped — every authenticated write overwrites
Meta.authorwith the authenticated principal, so authorship cannot be forged by a client. Open (disabled-auth) mode has no identity to stamp and stays a transparent store.
Scope is namespace-level read/write only; RBAC roles and a policy engine remain future work.
Consequences
- Positive: multi-tenant namespace isolation over both transports; existing single-token and open (local) deployments keep working unchanged; unforgeable authorship; the check lives in a pure, well-tested module.
- Negative: authorization is threaded through each handler (the namespace lives in the payload, not the headers); no sub-namespace (collection/id) granularity yet; tokens are static operator config with no rotation tooling.
- Revisit if: we need finer-grained (collection/record) permissions, roles, token rotation/issuance, or a real policy engine — the principal seam is where that slots in.
ADR 0016 · 3-way merge with content-addressed stored ancestry
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-threeway-merge-ancestry-design.md
Context
sync merges divergent records with an empty base, so merge() is a true
3-way merge only for AppendOnly (union is base-agnostic); Structured bodies
cannot distinguish a one-sided field edit from a genuine two-sided conflict. The
merge() machinery already implements correct 3-way semantics given a base — the
missing piece is the ancestor body, which the store does not retain (only the
current record per key is kept; parent is a revision hash, not content).
Options weighed for retaining ancestry: (a) a Store decorator that records each
body in a content-addressed store, (b) baking retention into every substrate's
put, or (c) sync-maintained watermark records. And for resolution scope: the
shared-parent case versus a full lowest-common-ancestor walk over retained
history.
Decision
We will add AncestryStore<S, B>, a Store decorator over any S: Store
plus a B: BlobStore. On a committed put it writes body.bytes() to the
ancestry blob store and delegates. Because Revision.hash == ContentHash::of(body.bytes()), each version's body is retrievable by its
revision hash. Retention is opt-in and changes no substrate.
sync gains sync_with_ancestry(a, b, ancestry: Option<&dyn BlobStore>);
sync(a, b) is preserved as the empty-base path. When two divergent records
share a parent revision (rec_a.parent == rec_b.parent) whose body is retained,
sync passes that body to merge() as the true base; otherwise it falls back to
the empty base (today's behavior). We scope resolution to the shared-parent
case — retained blobs carry no parent link, so a full LCA walk is not possible
without also retaining traversable history (out of scope).
Consequences
- Positive:
Structureddivergences from a real base now merge correctly (one-sided edits apply; same-field edits conflict). Ancestry reuses the existing content-addressedBlobStoreandRevisionhash — no substrate change, no new addressing scheme.AppendOnly/Opaque/Derivedbehavior is unchanged, and empty-base fallback keeps sync correct when ancestry is absent. - Negative: ancestry retention grows storage (every version's body kept) with no GC policy yet; only single-step (shared-parent) divergence gets true 3-way; the benefit requires peers to have wrapped their stores before the common write.
- Revisit if: multi-step divergence needs true LCA merges (retain parent links + walk), or retained ancestry growth needs a GC/retention policy.
ADR 0017 · Non-fast-forward git pull via content-aware merge
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-nonff-pull-content-merge-design.md
Context
GitStore::pull was fast-forward-only, erroring on divergence
("non-fast-forward pull requires manual merge"), so replication could not
reconcile a local branch that had diverged from its remote. git already retains
history, so the merge base commit is a real common ancestor — 3-way merge
needs no separate ancestry mechanism (unlike sync, ADR 0016). The open questions
were how record content is merged (git's line-based merge vs gonzalo's
class-aware merge()) and what happens to records that cannot be auto-merged.
Decision
We will make non-fast-forward pull perform a content-aware 3-way merge:
- Diff the merge base against local and remote; a record changed on only one
side takes that side, and a record changed on both sides is reconciled with
gonzalo's
merge(kind.merge_class(), base, local, remote)— never git's line-based content merge (which mis-handles structured JSON records). - Auto-merged records are rebuilt with a fresh revision and committed in a
two-parent merge commit that advances the branch; unresolved
(
NeedsResolution) records keep local and are surfaced in a newPullReport { fast_forwarded, merged, conflicts }rather than erroring — the pull makes progress and reports what it could not reconcile, mirroringsync.
pull returns PullReport (no existing callers). Fast-forward and up-to-date
paths are preserved. Scope is merge (not rebase); push and the Store surface
are unchanged.
Consequences
- Positive: diverged git peers reconcile automatically with record-correct semantics (append-only union, structured field-merge, etc.); a single unmergeable record no longer blocks pulling everything else; conflicts are reported with both sides for resolution.
- Negative: conflicted records keep local until the caller resolves them
(the merge commit records a partial reconciliation); no rebase option; the git
tree-walking/merge logic adds complexity to
gonzalo-store-git. - Revisit if: callers need rebase semantics, automatic conflict escalation, or conflict-marker artifacts for manual resolution.
ADR 0018 · Record deletion and its sync semantics
- Status: accepted
- Date: 2026-07-11
Context
The Store trait had no way to remove a record — only get, put, and list.
The caliban integration (#1)
needs deletion to prune memory topics, retire sessions, and sweep old
checkpoints, so Store must gain a delete. Two questions had to be settled:
- Concurrency.
putis OCC-aware (expected: Option<Revision>), surfacing aConflictrather than silently clobbering a concurrent write (ADR 0005). A delete that ignored the current revision could remove a record a peer had just updated. Should delete carry the same precondition? - Replication.
sync(ADR 0016) and gitpull(ADR 0017) reconcile two substrates by copying each side's records into the other. A delete leaves no trace, so a later sync against a peer that still holds the record cannot tell "deleted here" apart from "never seen here" — and copies it back. Do we need tombstones to propagate deletes, or is a local delete enough for now?
Decision
We will add Store::delete(key, expected: Option<Revision>) -> DeleteResult,
OCC-aware and mirroring put:
expected == Noneremoves the record if present and is an idempotent no-op if absent →DeleteResult::Deleted.expected == Some(rev)removes only if the current revision matches →Deleted; a mismatch leaves the record untouched and returnsDeleteResult::Conflictholding the live record. An already-absent key is an idempotentDeleted— the named revision is already gone, so there is nothing to conflict on.- The check-and-remove runs in the same critical section as the substrate's
put(fs: the per-record flock; git: the repo lock plus a commit of the removal; s3: a conditionalDeleteObjectwithIf-Match: <etag>), so it is atomic against a concurrent writer.DeleteResult::Conflict, like aputconflict, is a normal recoverable result, not an error.
We will make delete local-only. It is not a tombstone and is not
propagated by sync: a later sync against a peer that still holds the record will
resurrect it. Full tombstone propagation (a deletion marker that survives and
replicates so a delete on one substrate erases the record everywhere) is
deferred until multi-substrate delete-sync is actually needed.
Consequences
- Positive: callers get OCC-safe deletion across every substrate with the
same conflict semantics they already know from
put; the conformance suite exercises it on fs/git/s3/server. No new replication machinery, wire tombstone type, or GC of deletion markers to build and reason about yet. - Negative: delete does not replicate — a record deleted on one substrate and then synced from a peer that still holds it comes back. Callers who need a delete to stick across a sync must delete on both sides (or not rely on sync). This is a real, documented sharp edge until tombstones land.
- Revisit if: a consumer needs a delete on one substrate to propagate to its
peers — that is the trigger to design tombstone records, their replication in
sync/pull, and their eventual garbage collection.
ADR 0019 · Qualified S3 backend for HA: RustFS
- Status: accepted
- Date: 2026-08-01
- Source:
docs/superpowers/specs/2026-07-11-gonzalo-ha-soak-design.md§"Implementation finding"
Context
ADR 0004 made the backend a configuration choice behind one Store trait, and
its "revisit if" named the failure mode precisely: a needed backend cannot
satisfy the Store contract. Multi-replica gonzalod over an S3-compatible
store is the first case that hit it.
The contract at stake is optimistic concurrency (ADR 0005): concurrent edits are
never silently lost, and a loser is told (PutResult::Conflict). Over S3 that
reduces to one requirement — atomic If-Match conditional writes. Without
them the store degrades to check-then-set, and lost updates are silent, which is
the one outcome the core exists to prevent.
"S3-compatible" turned out not to imply this. gonzalo-store-s3's own
conformance case (concurrent_updates_with_same_expected_let_exactly_one_win,
ADR 0006) expects exactly 1 of 8 racers to commit, and became a backend
qualifier. Running it against candidates:
| Backend | Atomic If-Match | License | Outcome |
|---|---|---|---|
RustFS 1.0.0-beta.8 | ✅ deterministic (3/3 + full soak) | Apache-2.0 | chosen — Rust, MinIO-compatible, drop-in |
| MinIO | ✅ | AGPL | rejected — project sustainability |
| Garage | ❌ non-atomic | AGPL | disqualified — see below |
| SeaweedFS | ⚠️ setup-blocked, upstream CAS bugs | Apache-2.0 | not pursued |
| Ceph RGW | not tested | LGPL | heavyweight, against the modest-hardware goal |
Garage fails the invariant outright. v1.0.1 let 8/8 racers commit; v2.1.0
let 3–8 commit non-deterministically — the signature of check-then-set rather
than atomic CAS. Its own S3-compatibility reference documents no
If-Match/If-None-Match for PutObject, and v2.1.0 exposes no consistency or
quorum setting that changes this. It is a design limitation, not a tunable, so
there is no configuration under which Garage is safe for gonzalo.
MinIO passes the qualifier and is technically sound — it served as the control that proved the harness itself was correct. It is rejected on sustainability rather than correctness: through 2025 the project moved functionality out of the community edition toward its commercial offering and curtailed community development. Betting the persistence tier of an AGPL-3.0 project on a vendor actively narrowing what its open edition does is a risk we decline to take, independent of today's licence text.
Decision
We will treat atomic If-Match as a hard qualification gate for any
S3-compatible backend, and qualify backends by running the existing
conditional-write conformance case against them — not by reading compatibility
matrices.
RustFS is the qualified S3 backend for multi-replica HA: the only FOSS S3
store that passes, Rust, Apache-2.0, and drop-in MinIO-compatible. The HA soak
provisions it (docker-compose.rustfs.yml, scripts/rustfs-up.sh).
RustFS is pre-1.0 (beta), so this is a near-term answer, not a permanent one. The
mature foundation is a Postgres substrate (gonzalo-store-postgres, native
atomic CAS, aligned with prospero's clustered tier), tracked separately.
Garage's compose setup is retained solely as a reproducer of the finding, not as a supported backend. Nothing in the soak harness is backend-specific — it reads an S3 endpoint — so qualifying a new candidate is a matter of pointing it at one.
This does not narrow ADR 0004: the substrate remains configuration, and fs
remains the zero-dependency default. It records which concrete S3 servers clear
the contract that ADR 0004 requires every substrate to meet.
Consequences
- Positive: HA rests on a backend proven against gonzalo's own concurrency invariant rather than a vendor's compatibility claim. The qualifier is reusable, so future candidates are a test run, not an investigation. Both the chosen backend and the toolchain stay Rust and permissively licensed.
- Negative: we depend on a pre-1.0 beta for the HA path, and inherit its stability risk until the Postgres substrate lands. The qualification gate rules out most of the S3-compatible ecosystem, so "any S3 store" is a claim we can no longer make. Documents that named Garage as the target — the k8s system design and the gonzalo chart — are now wrong and need correcting.
- Revisit if: RustFS reaches 1.0 (upgrade from tolerated to preferred) or stalls; the Postgres substrate lands and supersedes it for HA; Garage or SeaweedFS ships atomic conditional writes and passes the qualifier; or a backend passes the qualifier but fails the invariant in the full soak, which would mean the qualifier is too weak.
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 designand 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
- Status: accepted
- Date: 2026-09-13
- Source:
docs/superpowers/specs/2026-09-13-tombstone-replication-design.md - Amended by: ADR 0029, under which a manifest-kind tombstone retains the deleted body instead of discarding it.
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_rawandlist_rawread tombstones;put_raw(record, expected)writes a record exactly as given;purgephysically 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; expectedequals 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--expectedis therevisionobject fromgonzalo 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 ofd,h,mors.
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
listskip 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
syncandpullon 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, anOpaquekind such asCheckpointreported 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
listhas to read each record to filter tombstones, which on s3 is aGetObjectper 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,listperformance on s3, and populatingMeta.created/Meta.updatedare follow-up work.git. Don't push a git store while
PullReport.conflictsis 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.putalready 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), andSyncReportdoes not signal when sync stops without converging (gonzalo#290). Independent deletes of the same revision converge, but each peer stamps its owndeleted_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
listcost 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
- Status: accepted
- Date: 2026-09-14
- Source:
docs/superpowers/specs/2026-09-14-fleet-access-control-records-design.md
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
readandwriteper namespace, so the key layout decides what a token can be limited to. AppendOnlyis unsafe for JSON bodies. TheAppendOnlymerge joins two bodies line by line. The first draft proposed it for audit entries, followingTicketEvent. 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.
| Kind | Key | Merge class |
|---|---|---|
Person | fleet/people/<person_id> | Structured |
IdentityBinding | fleet/identity-bindings/<authenticator>:<subject> | Structured |
RoleGrant | fleet/role-grants/<person_id>:<scope> | Structured |
ChannelConfig | fleet/channels/<provider>:<channel_id> | Structured |
LinkToken | fleet/link-tokens/<token_hash> | Opaque |
AuditEntry | fleet-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 onperson, so a wrong bind is surfaced. Two role changes to one grant conflict onrole. AChannelConfig'sreposarray merges atomically, so concurrent follows conflict rather than drop one. LinkTokenisOpaque. 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.AuditEntryisOpaque, notAppendOnly. 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.TicketEventis 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.
redeemchecks the hash, that the token hasn't expired, and that it isn't consumed, then returns the view withconsumedset. The consumer writes it with its read revision asexpected, so concurrent redemptions give oneCommittedand oneConflict. 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 justrepos— is a conflict an operator or consumer resolves; field-level merging only happens underpullorsync_with_ancestry. gonzalo can't stop a writer withfleetwrite 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 keepfleeton 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
reposconflicts 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
ChannelConfigshape andGrantScope::Repovariant 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:channelcollides across tenants — two different channels would share one record. repos: Vec<String>can't express "the whole fleet." Ariel's#opsexample 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, notowner/namerepos.- A free-form
filtersmap 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.followsstored 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:
- a record whose body is a
Body::Blob— the bytes are its content; - a tombstone's
deleted_blob— the pin above; - every slice a
graph-manifestnames (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 gcon 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
collectalready has. - Neutral:
Record::deleted_blobisOption, 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_blobsremains 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
- Status: accepted
- Date: 2026-09-19
- Source:
docs/superpowers/specs/2026-09-19-s3-tombstone-marker-layout-design.md - Amends: ADR 0021, which put the tombstone at the deleted record's own key and named the resulting S3 listing cost as work to revisit. The tombstone stays where 0021 put it; this adds a marker beside it.
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
resetandcollectget 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
PutObjectof 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 firstlistof 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:
| Module | Contents |
|---|---|
| root | the 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 |
memory | MemoryTier, Topic |
session | Session, Turn |
checkpoint | Checkpoint |
codec | RecordCodec |
ticket | the 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) |
fleet | unchanged — it already exists |
graph, vector, knowledge | the 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,ActororPagestops colliding with gonzalo's, anduse gonzalo::*becomes a reasonable thing to write again. Every import says which layer it came from. Two layers can use the same word —ticket::Pageandgraph::Pagecan 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-domainand 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
(
MemoryVectorIndexexact,HnswVectorIndexapproximate) or supersede it — it gives either one a durable home behind the sameVectorIndextrait.
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 unchangedVectorIndextrait, 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-vectornow requires a tokio runtime:RecordVectorIndex::openusesJoinSet::spawn, which panics outside a tokio runtime;tokio(featurert) 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 ofgonzalo-vectorwould break onopen. 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).keysis a required trait method, so this is a breakingVectorIndexchange for any out-of-tree implementor — there was no way to add it as a default that means anything for an arbitrary index.removeof 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 (commitnow returns early when its delta batch is empty), butremoveneeds a staged-vs-stored hash comparison insidecommitto 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:commitwrites a new shard's blob before itputs 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 nextopencannot read ("shard N names blob … but it is absent"). ADR 0028 narrows that for live writers: a sweep skips blobs younger thanmin_age(one hour by default), andcommitre-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. Runninggonzalo gcwhile 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, sogcmarks the shards live untilcollectremoves it, andgonzalo undeletecan restore the index within that horizon.syncdoes 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
VectorIndexseam) 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-vectorappears, which would forceopen'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_blobsreturnsVec<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_agedefaults to one hour (DEFAULT_MIN_AGE,crates/gonzalo-core/src/gc.rs).gc_blobs(store)andsweep_blobs(blobs, live)keep their signatures and use that default, so the safe behaviour is the default behaviour.gc_blobs_withandsweep_blobs_withtake an explicitSweepPolicy.nowis 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 sameparse_durationascollect --horizon;gonzalo index --gc, including under--watch, always uses the default.BlobEntrystoresmodified_unix_ms: i64, not aSystemTime(crates/gonzalo-core/src/store.rs), withfrom_system_timeandage(&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.
agereturnsNonefor 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 everyNoneas too young. A blob's own timestamp that overflows saturates toi64::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 inageand is kept), and anowthat overflows saturates toi64::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.
GcReporthas three outcomes,freed,retainedanddeferred(an unreferenced blob held back for being too young), counted in one loop so the partition is structural rather than derived by subtraction.freednow comes back inContentHashorder, not listing order, because the sweep walks aBTreeMap. The CLI printsdeferredfrom bothgonzalo gcand theindex --gcpath, 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 fromcommitonce the manifestputreportsCommitted. 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 withtry_existsand by S3 withHeadObject. A HEAD failure that is not "no such key" returnsErr, neverfalse:falsewould make a writer re-upload on every transient fault and hide a broken store.ServerStorekeeps 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
gcwould block for minutes and--watchwould stall behind it. - Two strikes across runs (delete only what two consecutive sweeps found
unreferenced). A first
gcwould 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 --gcunder--watchis covered with no flag, because the production callers passDEFAULT_MIN_AGE.deferredtells 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_ageis 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_agebefore they can be reclaimed, so disk is freed later than before and agcright after adeleteandcollectmay reportdeferredrather thanfreed. - Negative: listing blobs now costs a
statper blob.list_blobswent from oneread_dirtoread_dirplus astatof every blob, a real per-sweep cost on a large store under--watch --gc. - Negative, resolved:
ServerStore::has_blobdownloaded the blob, so the daemon path paid bandwidth for each newly referenced blob. TheHEADroute named here as a follow-up landed in gonzalo#329:HEAD /v1/blobs/{hash}and theHasBlobRPC answer from the daemon's own presence check, andServerStoreoverrides the default to use them. The check costs a stat on both sides now. One consequence of answering from presence:HEADreportscontent-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/blobsresponse indocs/api/openapi.jsonis not guarded by a test. What pins it is thehttp.rstest 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_bloband the S3 conformance cases are exercised only by CI's HA soak job, which setsGONZALO_S3_TEST_REQUIRED=1. They are skipped locally, so a local green run says nothing about them. - Negative:
list_blobsreturning entries is a breaking change for any out-of-treeBlobStoreimplementor, and for any client ofGET /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_agetoo 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
ServerStoredownload 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_kindfield. - Amends: ADR 0024, whose pin covered only
the blob a tombstone's
deleted_blobnamed. 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 becomeTombstone; 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.
Recordgainsdeleted_kind: Option<RecordKind>, besidedeleted_atanddeleted_blob, with the same#[serde(default, skip_serializing_if = "Option::is_none")]attributes, so records already on disk read unchanged.tombstone_ofkeepscurrent.bodyand setsdeleted_kindforGraphManifestandVectorManifestonly. Every other kind is exactly as before: empty body,deleted_blobpinning aBody::Blobhash,deleted_kind: None. - Marking.
live_blob_hashesgains an arm for aTombstonewhosedeleted_kindis a manifest kind, parsing the retained body with that kind's parser.deleted_kindis 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. collectis 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, thegonzalo undeletesubcommand and the facade re-export restore the manifest. It refuses, writing nothing, when there is no tombstone, the record is live,deleted_kindisNone, 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 underdeleted_kind, one counter past the tombstone, with the tombstone as parent andmeta.createdpreserved. It restores body, kind andmeta;linksare 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
RecordVectorIndexopened over a manifest tombstone used to open successfully.RecordVectorIndex::open(crates/gonzalo-vector/src/record_index.rs) read withstore.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 toopenitself, so the failing call is the one that was wrong.opennow reads raw and refuses on the same conditionplan_putdoes. - 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, soundeleterestores 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_winnercompares(counter, hash)with>=, so on a tie each peer keeps its own side, and bothsync(Relation::InSync) and gitpullthen 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'sundeleterestores its own manifest. - Negative: an older binary's
gcfrees the shards the pin protects.Recordhas nodeny_unknown_fields, so a binary that predatesdeleted_kindreads a manifest tombstone without error, but itslive_blob_hashesdoes not mark through the retained body, and re-serialising the record drops the field. Itsgctherefore 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 runsgcagainst 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
collectremoves it. That departs from "tombstones are tiny and uniform". - Negative: only manifest kinds can be undeleted, and past
collecta 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:
undeleterestores the record, not the world. If another index referenced the same shards and was itself deleted and collected, those blobs are gone andundeleterefuses, naming them. - Negative:
undelete'sPutResult::Conflictarm, 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_kindaddition to theRecordschema indocs/api/openapi.jsonis 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.
putever 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
gcfreeing 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 withstore.get, sees no record, and builds a fresh empty manifest. Its first commit is aRecord::createwithexpected: None.- The graph indexer in
gonzalo-clidoes the same for aGraphManifestkey aftergonzalo 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
expectedisNoneand the stored record is a tombstone whosedeleted_kindisGraphManifestorVectorManifest,plan_putreturnsPutPlan::Rejected(MANIFEST_TOMBSTONE_RECREATE_REJECTED). The constant is incrates/gonzalo-core/src/tombstone.rsand re-exported from the crate root. Its message points at the two exits:gonzalo undeleteto restore,gonzalo purgeto discard. - The trigger is an explicit kind match, not
deleted_kind.is_some().deleted_kindisserde(default), andplan_put_rawstores replicated records verbatim, so a tombstone arriving from a peer can carry anydeleted_kindit likes. A foreign tombstone with a non-manifest value has no pin and no restore window; refusing a create over it would protect nothing, andundeletewould refuse it too, stranding the key withpurgeas the only exit. Matching the two manifest kinds keeps the refusal exactly as wide as the window it guards. - Surface.
PutPlan::Rejectedalready carries a&'static strand every substrate already maps it toCoreError::Invalid, which the daemon answers as HTTP 400 / gRPCInvalidArgument, not 500. No new plan variant,Storemethod, wire field or substrate code is needed. ThePutPlan::Rejectedrustdoc previously saidBackend; that was stale and now saysInvalid. - Replication and
undeleteare untouched.plan_put_rawis unchanged, so a replicated create over a tombstone stillConflicts andundeletestill 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 withoutGONZALO_S3_TEST_ENDPOINT/BUCKET; the ordinary gate covers four substrates) and asserts the tombstone's revision, retained body anddeleted_kindall 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), unlikegonzalo 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::purgeandplan_purgeare 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
undeletefirst. 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
collectwith a horizon that swept others too. - Negative:
gonzalo resetfollowed bygonzalo indexnow fails until the operator runsgonzalo purgeorgonzalo undeleteon each manifest key. That is friction by design:resettombstones manifests, and rebuilding over them used to discard the window silently. - Negative: a
putthat 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 strand cannot name the key. The caller already knows which key it wrote. - Negative, resolved in gonzalo#340: a
RecordVectorIndexopened over a tombstone used to open successfully and fail only at its first commit.openread throughstore.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.opennow reads raw and refuses on this ADR's own condition — the stored tombstone'sdeleted_kindnaming 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 runsgonzalo purgefirst, 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
openmeans 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 betweenopenand the commit.RecordVectorIndexstages 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 bygc, but it is a new way to reach it. - Negative, resolved by ADR 0031:
the daemon exposed
Store::purgeto 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::purgenow refuses to remove a live record, narrowly enough to leave collection's OCC-raceConflictintact. - Negative, resolved by ADR 0031:
a principal with plain
Writeon a namespace could destroy the restore window through the raw route.PUT /v1/raw/records/{ns}/{col}/{id}authorizesAccess::Write, not admin (crates/gonzalo-server/src/http.rs:382-385; comparepurge_recordat:420, which requires admin), andGET /v1/raw/...needs onlyAccess::Read. A tombstone's revision is{live_counter + 1, tombstone_hash()}, andtombstone_hash()(crates/gonzalo-core/src/tombstone.rs:21) is a constant, so the revision is predictable even without reading it. Such a principal canput_rawa fresh empty manifest naming that revision, and the retained body, the pin andundeleteall go: no race and no admin token. The fix belongs at the route, not inplan_put_raw, whose matching-revision arm is whatundeleterides. Tracked in gonzalo#342, a sibling of #341 that needs lower privilege. The route now requires admin, matchingPOST /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::Rejectedgains 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::openlearned 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; itsplan_putguard andgonzalo purgeescape hatch stand unchanged. - Amends: ADR 0015, which authorized
the raw replication write with
writeon 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
collectlosing an OCC race to a recreate between listing and purging.plan_purgeanswersConflictand removes nothing, whichcollectrecords inCollectReport::conflicts. Refusing there instead would turn a routine race into a hard failure of the whole collection run — caught byconformance::purge_conflicts_after_recreationacross 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 whatundeleterides, so refusing there would break the capability the guard exists to protect. - A guard inside
plan_purge. That is core, shared bycollectand every substrate, and it is where the kind-blindness is load-bearing:collectneedsConflict, not an error, and ADR 0030 deliberately kept kind-awareness out of core. The check belongs at the operator surface, which is whatServiceis 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_rawbegins returning403/PermissionDeniedwhere 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/InvalidArgumentwhere it previously succeeded. Tombstone collection is unaffected; a caller deliberately removing a live record must usegonzalo deleteand then purge the tombstone, which is the sequence that keeps peers convergent. - Negative:
Service::purgecosts one extraget_rawper 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::openread throughstore.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_rawper purge shows up in collection timings on a large store. The revision-hash shortcut — a tombstone's hash is the constanttombstone_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.