Three changes that go together:
1. Reconcile cli/plans/2026-04-24-file-event-store-{overview,nips}.md
with shipped reality: code lives in quartz/jvmMain/, not commons/;
data dir is <data-dir>/events-store/, not <root>/events/.
2. New StoreCommands wired as `amy store …`:
- stat → events count, kind histogram, disk bytes,
oldest/newest createdAt. Pure read, no Context
(skips identity check).
- sweep-expired → wraps store.deleteExpiredEvents(); reports
{swept, remaining}.
- scrub → wraps store.scrub() to rebuild idx/ from
canonicals.
- compact → wraps store.compact() to drop dangling idx/.
All four open the FsEventStore directly (no Context, no identity
needed) — they're store-only operations.
3. New e2e harness at cli/tests/cache/cache-headless.sh that boots a
local nostr-rs-relay, two amy identities (A + B), and asserts:
T1 — store stat reports non-empty store after publish-lists +
profile edit, with kind:0 and kind:10002 present.
T2 — A's `profile show` is `source: "cache"` by default.
T3 — `--refresh` forces `source: "relays"`.
T4 — B's first `profile show <A_NPUB>` is a relay miss; second is
a cache hit (proves drain populates the local store and
subsequent reads serve from disk).
T5 — `relay list` reads URLs back from the local kind:10002 /
10050 / 10051 events.
T6 — relays.json no longer exists in either data-dir.
T7 — store stat / sweep-expired / scrub / compact all run
without an identity present.
Same pattern as cli/tests/dm/dm-interop-headless.sh — reuses the
nostr-rs-relay infrastructure from cli/tests/marmot/setup.sh.
16 KiB
File-backed event store for amy — Part 3: NIPs, Enforcement, Scrub, Testing
Status: plan · Date: 2026-04-24 · Part 3 of 3
(see 2026-04-24-file-event-store-overview.md,
2026-04-24-file-event-store-pipelines.md)
Per-NIP behavior, slot enforcement details, tag indexing, scrub, and test plan.
NIP-01 — Replaceable events (kinds 0, 3, 10000–19999)
Enforcement
- Slot path:
replaceable/<kind>/<pubkey>.json - Slot is a hardlink into
events/<aa>/<bb>/<id>.json— same inode, two names. - Directory-entry uniqueness is the
UNIQUE(kind, pubkey)constraint (parity withReplaceableModule.kt:25-60).
Insert flow (inside transaction T2/T5 from Part 2)
guard = ReplaceableGuard
if slot exists:
Told = read slot JSON → createdAt
if event.createdAt <= Told: REJECT # blocks older from reinsert
install (at T5):
slotTmp = <slot>.tmp.<rand>
ln events/<new_id>.json slotTmp
Files.move(slotTmp, slot, ATOMIC_MOVE, REPLACE_EXISTING)
# Old slot's inode loses one link. If it had a canonical hardlink too,
# drop it — mirror SQLite trigger that deletes the old row.
if oldId present:
runDeleteLinksFor(oldId) # same code path as delete()
Reading the current version
cat replaceable/3/<pubkey>.json # latest kind-3 for pubkey
Direct path open. No scan, no MAX — the slot is the winner.
Edge cases
- Race between two newer events. Both pass guard, both try to
install; last
renamewins atomically. Losing event's canonical exists orphaned until scrub or until someone queries by its id. Semantically valid: both were newer than the previous slot. - Slot deleted by user. Next insert of (kind, pubkey) has no guard and wins. This matches "files come and go" contract.
- Canonical deleted but slot survives. Slot still resolves — hardlink keeps inode alive. Queries work. Scrub re-creates the canonical from slot.
NIP-01 — Addressable events (kinds 30000–39999)
Enforcement
- Slot path:
addressable/<kind>/<pubkey>/<sha256(d_tag)>.json - SHA-256 of d-tag value avoids filesystem-unsafe characters and
collisions. Parity with
AddressableModule.kt:25-63. - Empty d-tag is valid Nostr — hashed as
sha256("").
Flow
Same as replaceable, keyed by (kind, pubkey, d_tag). Guard reads
slot's createdAt; install via rename REPLACE_EXISTING; cascade-
unlink old canonical and its index hardlinks.
Query shortcut
If a filter supplies kinds, authors, and a d-tag, the planner
bypasses the driver indexes and opens the slot directly. O(1).
NIP-09 — Event deletion (kind 5)
Deletion event insert
When a kind-5 event is inserted (inside T6 of the pipeline):
for target in event.deleteEventIds():
# NIP-09: same author as the deletion
targetPath = events/<aa>/<bb>/<target>.json
if targetPath exists AND read(targetPath).pubkey == event.pubkey:
runDeleteLinksFor(target) # cascade unlink
# Install tombstone (hardlink to the kind-5 event):
tomb = tombstones/id/<target>
ln event_canonical <tomb>.tmp
Files.move(<tomb>.tmp, tomb, ATOMIC_MOVE, REPLACE_EXISTING_IF_NEWER)
for addr in event.deleteAddresses():
hash = sha256(addr.dTag)
slot = addressable/<addr.kind>/<addr.pubkey>/<hash>.json
if slot exists AND read(slot).pubkey == event.pubkey:
Told = read slot.createdAt
if Told <= event.createdAt:
oldId = read slot.id
unlink slot
runDeleteLinksFor(oldId)
tomb = tombstones/addr/<addr.kind>/<addr.pubkey>/<hash>
ln event_canonical <tomb>.tmp
Files.move(<tomb>.tmp, tomb, ATOMIC_MOVE, REPLACE_EXISTING_IF_NEWER)
for replaceable_kind in event.deleteReplaceableKinds():
# kinds 0,3,10000-19999 — no d-tag
slot = replaceable/<kind>/<event.pubkey>.json
similar cascade + tombstone
REPLACE_EXISTING_IF_NEWER is a helper we implement: if an older
kind-5 already owns the tombstone, and the new one has a later
createdAt, swap it in; otherwise keep the existing. Ensures
tombstone holds the strongest (latest) deletion cutoff, matching
SQLite's "OR" of all deletion events
(DeletionRequestModule.kt:102-184).
Blocking re-insertion
Part of T2 guards:
TombstoneIdCheck:
if tombstones/id/<event.id> exists → REJECT
TombstoneAddrCheck (addressable only):
if tombstones/addr/<k>/<pk>/<hash(d)> exists:
Tdel = read tombstone.createdAt
if event.createdAt <= Tdel → REJECT
Parity with DeletionRequestModule.kt:61-75 (BEFORE INSERT trigger
rejecting events whose etag_hash/atag_hash match a kind-5 entry).
Gift-wrap deletion by p-tag
EventIndexesModule.kt:161-166 uses pubkey_owner_hash that, for
GiftWrapEvent, is the recipient's p-tag (not the outer pubkey).
We mirror this: idx/owner/<owner_hash>/<ts>-<id> points at every
event where ownerHash = TagNameValueHasher.hash(recipient).
A kind-5 from the recipient deleting by e-tag still resolves via
runDeleteLinksFor(id), which walks all hardlinks (including
idx/owner/) and unlinks them.
Security: author check
NIP-09 requires deletions be authored by the same pubkey as the
deleted event. SQLite enforces this via pubkey_owner_hash in the
DELETE WHERE clause (DeletionRequestModule.kt:107-115). We mirror
by reading the target's JSON and comparing pubkey — done inside
runDeleteLinksFor.
NIP-40 — Expiration
Read at insert
event.expiration() returns Long? (tag value as unix seconds).
if exp != null && exp > 0:
if exp <= now: throw ExpiredEventException # T1 guard, mirrors trigger
ln canonical idx/expires_at/<zero_padded(exp)>-<id>
Parity with ExpirationModule.kt:27-95.
Sweep
deleteExpiredEvents() listed in Part 2. Uses the sorted
idx/expires_at/ — early termination when <exp> ≥ now.
Recommended caller: cron or shell loop.
while :; do amy store sweep-expired; sleep 900; done
Matches the ExpirationWorker WorkManager pattern from the SQLite
README.
Query-time filtering
Belt-and-suspenders: queries also filter out events where
exp != null && exp <= now. Covers sweep-missed windows and
clock skew between writer and reader.
NIP-62 — Right to vanish (kind 62)
Insert
if !event.shouldVanishFrom(relayUrl): return # same scoping as SQLite
ownerHash = TagNameValueHasher.hash(event.pubkey)
# Install vanish tombstone (one per pubkey; latest wins)
tomb = tombstones/vanish/<ownerHash>
existing = read tomb if exists
if existing == null || existing.createdAt < event.createdAt:
ln canonical <tomb>.tmp
Files.move(<tomb>.tmp, tomb, ATOMIC_MOVE, REPLACE_EXISTING)
# Cascade: delete every event from this owner with createdAt < tomb.createdAt
for entry in idx/owner/<ownerHash>/:
(ts, id) = parse entry filename
if ts < event.createdAt:
runDeleteLinksFor(id)
Parity with RightToVanishModule.kt:46-71.
Blocking future inserts
Part of T2 guards (VanishCheck):
tomb = tombstones/vanish/<TagNameValueHasher.hash(event.pubkey)>
if tomb exists:
Tvanish = read tomb.createdAt
if event.createdAt <= Tvanish → REJECT
For GiftWraps, owner is the recipient — a vanish request from the
recipient cascades their received gift-wraps too, matching SQLite's
behaviour via pubkey_owner_hash.
NIP-45 — Count
count(filter) / count(filters) runs the exact query plan from
Part 2 and returns the number of surviving entries. Parity with
SQLite SELECT count(*).
Optimisation: if the filter is driver-only (no post-driver code
filter), we just count directory entries — Files.list().count() —
without opening any JSON. Linear in the driver's listing size.
NIP-50 — Full-text search
Tokenisation
Reuse Quartz's SearchableEvent.indexableContent() (per
FullTextSearchModule.kt:66-78). Tokeniser:
- Lowercase.
- Split on Unicode word boundaries.
- Filter tokens shorter than 3 chars (FTS5 default).
- Keep ASCII hex ids and bech32 identifiers whole (no splitting on underscores/digits).
Match the Android FTS5 tokeniser behaviour; a shared helper lives
in commons/ and is called by both backends to guarantee identical
search results.
Index
idx/fts/<token>/<id> # empty hardlink into canonical
One empty hardlink per (token, event). For a 1000-word note with
500 unique tokens, 500 hardlinks — each costs one directory entry
(~~50 bytes on ext4), no inode. At scale this is the biggest
directory count; idx/fts/ alone may hold millions of entries.
Still fine — each token dir stays small.
Search query
tokens = tokenize(filter.search)
streams = tokens.map { idx/fts/<it>/ }
candidates = k-way intersection of streams by <id>
for candidate in candidates:
if passes all other filter predicates:
emit
Same semantics as SQLite MATCH in FTS5 AND-mode. Matches
QueryBuilder.kt:590-657.
Maintenance
- On insert: write token hardlinks (T4).
- On delete (any path — direct, replaceable replace, vanish,
expiration):
runDeleteLinksFor(id)enumerates every possible token and unlinks. We don't store the token list, so we re-derive it by reading the canonical content before unlinking the canonical. If the canonical is already gone, we fall back tofind -samefileto enumerate remaining hardlinks.
Without an index
amy store search --raw "keywords" can fall back to
ripgrep events/ for forensic use. Always works, even if indexes
are corrupt.
Tag indexing
Which tags get indexed
Matches DefaultIndexingStrategy
(IndexingStrategy.kt:99-102): tag arrays with at least 2 elements
where tag[0].length == 1 (single-letter tag names). Non-single-
letter tags are stored in the JSON and visible to queries but not
reverse-indexed.
Hash scheme
Reuse TagNameValueHasher
(quartz/.../sqlite/TagNameValueHasher.kt:30-67). Reads .seed
into a Long salt identically to SQLite's seeds table. The hash
becomes a 16-char hex string that names the directory:
idx/tag/<name>/<hex(hash(name, value))>/<ts>-<id>
Parity with event_tags.tag_hash. d-tag, e-tag, a-tag, p-tag,
owner: see below.
Special tags
| Tag | Where indexed | Notes |
|---|---|---|
d |
Only as part of addressable/<k>/<pk>/<sha256(d)>.json slot |
Not in idx/tag/d/ |
e |
idx/tag/e/<hash>/ |
Normal |
a |
idx/tag/a/<hash>/ + addressable slot look-aside |
Normal |
p |
idx/tag/p/<hash>/ |
Recipient for GiftWrap also goes to idx/owner/ |
| owner (synthetic) | idx/owner/<owner_hash>/ |
Event pubkey for normal events, recipient for GiftWrap — matches pubkey_owner_hash (EventIndexesModule.kt:161-166) |
Seed file
.seed is 8 random bytes generated on store creation. Read into a
Long salt for TagNameValueHasher, matching SQLite's seeds table
(SeedModule.kt:26-84).
- Written atomically (tmp-rename) on first open if absent.
- Never updated. Corrupt or mismatched seeds invalidate
idx/— scrub rebuilds from canonical. - Permissions
400.
Scrub / rebuild
FsEventStore.scrub():
1. Lock .lock exclusively.
2. Delete .staging/ leftovers.
3. Enumerate all events/<aa>/<bb>/<id>.json.
4. For each, re-derive:
idx/kind/, idx/author/, idx/owner/, idx/tag/*/,
idx/fts/*/, idx/expires_at/
using the same code as T4. createLink is idempotent — NOACT on
collision (or unlink + recreate).
5. Walk idx/ trees; drop entries whose target inode count is zero
(dangling — canonical was deleted).
6. Rebuild replaceable/ and addressable/ slots from canonicals by
grouping (kind,pubkey[,d]) and picking max createdAt.
7. Keep tombstones as-is. (Removing a tombstone file is a user-
intended action; scrub does not resurrect them.)
8. Release lock.
Cost: O(N) disk reads once per scrub. Acceptable as a manual
amy store scrub command. Not run automatically.
compact()
Lighter: walk idx/ only, drop dangling entries. No canonical
reads. O(size of idx trees).
Testing strategy
Location
quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/
(sibling to the SQLite reference tests). Uses kotlin.test (matches
the rest of the quartz jvmTest source set). Temp-dir fixture
creates a fresh store per test.
Test categories
-
Parity tests. Drive both
SQLiteEventStoreandFsEventStorewith identical event streams and filters; assert query results match. Existing SQLite tests underquartz/src/jvmTest/.../sqlite/become the golden set. This is the highest-value tier. -
Per-module tests. One file per enforcement area:
ReplaceableSlotTest— newer wins, older rejected, race deposes both cleanly.AddressableSlotTest— same, plus d-tag hash collisions cannot occur (SHA-256).DeletionTombstoneTest— by id, by address, by replaceable kind; re-insert blocked; gift-wrap by owner.VanishTest— cascade + block, GiftWrap recipient.ExpirationTest— sweep, early termination, belt-and- suspenders query filter.SearchTest— tokeniser parity with SQLite FTS, AND of tokens, maintenance across replace/delete/expire/vanish.TagAndTest— NIP-91 intersection, three-tag filters.EphemeralTest— never written.TransactionTest— all-or-nothing batch insert, rollback on exception.
-
Crash-safety tests. Inject failures after each pipeline step (T1–T8) via a testing hook; reopen; assert scrub converges.
-
Concurrency tests. Two threads / two processes hitting the same store: assert no corruption, writes serialise, readers see consistent snapshots.
-
External edit tests. Mid-run, delete a canonical file / a slot / a tombstone; assert store behaves as spec'd (dangling index skipped; slot rebuilds; tombstone stops enforcing).
-
Property tests. For a random sequence of (insert, delete, query, vanish, expire, replace) operations, assert the file store and SQLite store produce identical
query()results. Seed = test parameter.
Running
./gradlew :commons:jvmTest --tests "*.store.fs.*"
Target: all tests pass before the CLI starts depending on the new store.
Rollout plan
- Step 1 — skeleton.
FsEventStoreclass, directory layout,insert+query(ids only)+delete(id). Round-trip test. - Step 2 — indexes. kind/author/owner/tag hardlinks; query by
kind, author, tag;
count. - Step 3 — slots. Replaceable + addressable enforcement + tests.
- Step 4 — tombstones. NIP-09 deletion by id and address; block re-insert; gift-wrap via owner.
- Step 5 — expirations.
idx/expires_at/,deleteExpiredEvents. - Step 6 — vanish. NIP-62 insert + cascade + block.
- Step 7 — FTS. Tokeniser in
commons/,idx/fts/, search queries. - Step 8 — transactions, concurrency, scrub.
- Step 9 — wire into
amy.Contextgains astore: IEventStoreproperty; default =FsEventStorerooted at$AMY_HOME/events/. - Step 10 — parity test matrix driven by the existing SQLite golden tests.
Each step is a separate commit. Each is behind AMY_EXPERIMENTAL_ STORE=1 env guard until step 10 passes.
Out of scope for this plan
- Android integration — stays on SQLite.
- Desktop opt-in — separate plan once CLI is stable.
- Cross-host sync — users can rsync the directory; that's it.
- Encryption at rest — future plan; would wrap each event file.
Summary of plan split
| File | Covers |
|---|---|
2026-04-24-file-event-store-overview.md |
Goals, layout, feature matrix, API |
2026-04-24-file-event-store-pipelines.md |
Insert, query, delete, transaction, concurrency |
2026-04-24-file-event-store-nips.md (this file) |
Per-NIP enforcement, slots, tombstones, FTS, scrub, testing |