A band held ONE created_at interval per (relay, filter). For a filter naming several kinds that is a claim no walk can support: ask for `kinds: [0, 30382]`, find profiles going back years and score cards only from last month, and the band records 2020..now for the pair. The next run then skips that whole interior for BOTH — so score cards written inside it are never asked for again, and nothing anywhere says so. A long-lived kind vouched for a short-lived one. Band.spans is now per kind. Each carries only the evidence actually collected for it, so the profile kind keeps its wide interval and the score kind keeps its narrow one, and legs() re-opens the interior for the second while still skipping it for the first. Three things keep the cost of that where it was: - legs() REGROUPS kinds by the windows they want. Identical coverage — the common case, and the only case until they diverge — collapses back into one ask, so a filter that produced two legs still produces two rather than two per kind. Only a kind whose evidence genuinely differs earns its own. - A finished reconcile needs no per-kind evidence and is given none: negentropy compares the filter's whole id set in one pass, so it covers every kind in the filter or none. Only the PAGED path changed. - Filters naming no kinds keep a single span under ALL_KINDS, which is the same claim as before, correctly scoped to the case where it is the only claim available. record() takes observedByKind, and SyncCoverage.observe() accumulates it as events arrive — replacing the pair of hand-rolled vars each caller kept, and moving the per-event isPlausible guard in with it. A paged walk over a MULTI-kind filter that supplies none earns no band at all, loudly, once: attributing one interval to every kind is exactly the over-claim this removes, and a band that over-claims skips events silently, which is worse than re-reading them. Single-kind filters are untouched — there the aggregate always was the per-kind answer. The state file gains a per-kind `spans` object and keeps `min`/`max` as the outer edges, so a rollback to a binary from before this reads the file and behaves as it always did. A file written BEFORE this loads its one interval under ALL_KINDS — the old, wider claim, kept rather than discarded because discarding it would re-download every upstream's corpus once on upgrade. The first per-kind walk replaces it. All 26 existing SyncCoverage tests pass unchanged, which is the evidence that single-kind behaviour did not move. The five new ones were checked against the pre-fix rule reinstated in place: the two behavioural ones fail there and pass here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
geode
A standalone Nostr relay for the JVM, built on Quartz's relay-server code (Ktor CIO). It speaks the core relay protocol plus NIP-11 (info doc), NIP-42 (AUTH), NIP-45 (COUNT), NIP-50 (full-text search), NIP-77 (Negentropy sync), and NIP-86 (relay management), stores events in SQLite (or a filesystem backend), and can mirror upstream relays strfry-router style.
geode depends only on :quartz — no Android, no Compose. amy serve (the
Amethyst CLI) embeds the same engine in-process.
Install
Pick the channel that matches how you deploy. For a real relay, the Docker
image or the .deb/.rpm + systemd are the two production paths;
Homebrew and the portable tarball are handy for local testing.
Docker (recommended for servers)
Images are published to GHCR on every release:
# Ephemeral — in-memory store, events vanish on restart:
docker run --rm -p 7447:7447 ghcr.io/vitorpamplona/geode:latest
# Persistent + configured:
docker run -d --name geode -p 7447:7447 \
-v $PWD/geode.toml:/etc/geode/geode.toml:ro \
-v geode-data:/var/lib/geode \
ghcr.io/vitorpamplona/geode:latest --config /etc/geode/geode.toml
with in_memory = false and file = "/var/lib/geode/events.db" in your
geode.toml. Pin a version (:1.13.1) instead of :latest for reproducible
deploys. To build the image yourself, from the repo root:
docker build -f geode/Dockerfile -t geode:local .
Debian/Ubuntu (.deb) and Fedora/RHEL (.rpm)
Download geode-<version>-linux-x64.deb (or .rpm) from the
release page and install
it — a minimal JRE is bundled, so no system Java is required. It installs to
/opt/geode/ with the launcher at /opt/geode/bin/geode.
sudo dpkg -i geode-1.13.1-linux-x64.deb # or: sudo rpm -i geode-1.13.1-linux-x64.rpm
To run it as a managed service, wire up the shipped systemd unit
(/opt/geode/share/geode/geode.service) — the unit file's header comment has
the copy-paste steps (create the geode user, drop your config in
/etc/geode/geode.toml, systemctl enable --now geode).
Homebrew
brew install vitorpamplona/tap/geode # from the tap, once published
The formula depends on openjdk and installs the no-JRE jar bundle. Reference
formula: packaging/homebrew/geode.rb.
Portable tarball (any Linux/macOS, no system Java)
Download geode-<version>-<os>-<arch>.tar.gz, unpack, and run:
tar xzf geode-1.13.1-linux-x64.tar.gz
./geode/bin/geode --config geode/share/geode/config.example.toml
The tarball bundles a jlink'd JRE plus share/geode/config.example.toml and
share/geode/geode.service.
From source
./gradlew :geode:run --args="--config /etc/geode.toml"
Configure
geode runs with zero config (binds 0.0.0.0:7447, in-memory SQLite). For
anything real, copy config.example.toml and edit it —
the sections mirror nostr-rs-relay's config.toml so existing configs port
almost verbatim. Precedence is CLI flags > TOML > built-in defaults.
geode --config /etc/geode/geode.toml
geode --help # full flag list
geode --version
Key sections: [info] (NIP-11 doc), [network] (bind + thread pools),
[database] (SQLite path/tuning), [options] (AUTH / verify / search),
[authorization] (allow/deny lists), [[mirror]] (upstream mirroring), and
[admin] (NIP-86 management). See the example file for every knob.
Verbs
geode [relay] [flags] serve the relay (default)
geode import [flags] [FILE…] bulk-load NDJSON events into the store
geode export [flags] dump the store as NDJSON to stdout
import/export are geode's strfry import / strfry export — one JSON event
per line, for seeding, migrating, or backing up a relay. Both stream, so a
multi-million-event corpus round-trips in roughly constant memory.
Release process
geode ships on the same tag-driven pipeline as the rest of Amethyst: pushing a
v* tag runs .github/workflows/create-release.yml, whose build-geode job
produces the tarball + .deb/.rpm + Homebrew jvm bundle, docker-geode
builds and pushes the GHCR image, and bump-homebrew-geode-formula.yml syncs
the reference formula. Design notes:
plans/2026-07-24-geode-release.md.