Files
amethyst/.claude/skills/event-store-semantics/SKILL.md
Vitor PamplonaandClaude Opus 5.5 395af4dc9c chore(skills): consolidate 33 skills into 14, verified against the tree
The 33 descriptions (~16k chars, ~19k with paths) overran Codex's skill-list
budget (2% of the context window, or 8,000 chars when unknown), and a review
found most bodies stale: paths from before the commons/commonsUI moves, the
deleted legacy desktop app, a callback signer API that no longer exists, and
example APIs that were never in the tree.

Kept, rewritten and checked against the code (every path, class and command):
account-state (+feeds), amy-expert, android-expert, auth-signers,
compose-expert (+API style, Navigation 3 back stacks), desktop-expert
(+run/package), event-store-semantics (+NIP-50 search), find-missing-
translations, kotlin-coroutines (+structured concurrency, flow modeling),
kotlin-multiplatform (+Gradle, composable extraction), ngit-pr,
nip85-trusted-assertions, nostr-expert (+NIP lookup, generated NIP catalog),
relay-client.

Dropped: textbook Compose/Kotlin technique skills, kotlin-expert,
kotlin-types-value-class, gradle-expert, quartz-integration (external-facing;
see the Quartz docs commit), find-non-lambda-logs (audit finished; the rule is
now in AGENTS.md Kotlin Style), and the /nip, /desktop-run, /extract commands.
The skill list is now ~4k chars. Also drops .claude/core-skills-plan.md
(stale history) and repoints SearchableEvent's KDoc to event-store-semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:21:01 -04:00

9.1 KiB
Raw Permalink Blame History


name: event-store-semantics description: Quartz IEventStore/SQLite store contract (STORE-* rules) and NIP-50 search: filter semantics, replaceable/deletion/expiry writes, counts, FTS, which kinds are SearchableEvent. Use for store parity, geode, or making a kind searchable.

Event store semantics and NIP-50 search

The SQLite EventStore (quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/) is the reference implementation of a Quartz event store. The filesystem store (quartz/src/jvmMain/.../nip01Core/store/fs/, held to parity by quartz/src/jvmTest/.../store/fs/FsParityTest.kt), geode and external engines (e.g. a Vespa-backed store) reimplement its observable behavior. Every behavior is a numbered rule in references/store-rules.md so a divergence is a lookup. When you change store behavior, update that rule and its changelog in the same PR.

Key files (under nip01Core/store/)

Concern File
Public contract (KDoc is normative) IEventStore.kt, RejectionReason.kt
High-level store (pool + planner) sqlite/EventStore.kt, sqlite/SQLiteEventStore.kt
Filter → SQL, ordering, limits, counts sqlite/QueryBuilder.kt
k-way merge fast path (feed shapes) sqlite/MergeQueryExecutor.kt
Schema, tag hashing, immutability sqlite/EventIndexesModule.kt, sqlite/TagNameValueHasher.kt, sqlite/SeedModule.kt
Replaceable / addressable supersession sqlite/ReplaceableModule.kt, sqlite/AddressableModule.kt
NIP-09 / NIP-40 / NIP-62 / ephemeral sqlite/DeletionRequestModule.kt, sqlite/ExpirationModule.kt, sqlite/RightToVanishModule.kt, sqlite/EphemeralModule.kt
NIP-50 FTS sqlite/FullTextSearchModule.kt
Index / feature toggles sqlite/IndexingStrategy.kt (client default); geode's geode/src/main/kotlin/com/vitorpamplona/geode/RelayIndexingStrategy.kt (relay preset)
Operations (concurrency, pragmas, maintenance) sqlite/README.md

Executable spec: quartz/src/commonTest/.../nip01Core/store/sqlite/ (BasicTest, ReplaceableTest, AddressableTest, DeletionTest, ExpirationTest, RightToVanishTest, SearchTest, SearchRelevanceOrderTest, NoFullTextSearchTest, MergeQueryCorrectnessTest, TagMergeCorrectnessTest, QueryAssemblerTest, SnapshotIdsForNegentropyTest, InsertOutcomeClassificationTest) plus nip01Core/relay/filters/FilterMatcherTest. A test beats a rule; fix the rule.

Rule index (full text in references/store-rules.md)

  • F01 since/until inclusive · F02 ids/authors exact 64-hex only, no prefix match · F03 tag values OR within a name, AND across names; tagsAll = every value · F04 only single ASCII-letter tag names indexed (isIndexableTagName) · F05 #d matched on event_headers.d_tag, use it only via plain tags · F06 tag matching is 64-bit hash, no post-verify · F07 multi-filter = UNION, limit per filter, no global limit · F08 created_at DESC; same-second order unspecified unless useAndIndexIdOnOrderBy · F09 merge fast path returns the same set · F10 empty filter matches all, but delete(Filter()) deletes nothing · F11 empty lists are invalid input · F12 limit = 0 → none · F13 FilterMatcher is separate and simpler.
  • W01/W02 replaceable/addressable: newest wins, tie → lowest id, stale version rejected REPLACED (OK false) · W03 ephemeral acked, never stored · W04 expired rejected at insert · W05 expiry not enforced at query time (sweep) · W06 gift-wrap owner = p recipient · W07 rows immutable · W08 batch: savepoint per row · W09 failures classified by querying the DB, not driver text.
  • D01 kind-5 by id, same owner, no time check · D02 by address, created_at <= · D03 cross-author kind-5 inert · D04 re-insert blocking · D05 kind-5 can delete a kind-5 (known quirk) · D06/D07 NIP-62 vanish relay-scoped, strict delete / inclusive block · D08 manual deletes create no blocking.
  • C01 count = deduped match set honoring per-filter limits.
  • S01 search extensions stripped before FTS · S02 bm25 relevance order · S03 search ANDs with structure · S04 FTS5 MATCH grammar · S05 FTS off → search matches nothing · S06 deferred FTS catches up before a search.
  • N01 negentropy snapshots use query semantics.

Kind classes everywhere: replaceable = 0, 3, 10000–19999; ephemeral = 20000–29999; addressable = 30000–39999.

Searchable events (NIP-50 indexing surface)

nip50Search/SearchableEvent.kt:

interface SearchableEvent {
    fun indexableContent(): String                                   // write path: stores call it once per insert
    fun forEachIndexableField(visitor: IndexableFieldVisitor) { … }  // read path: allocation-free cache scans (EventSearchMatcher)
    fun indexableSeparator(): String = "\n"                          // what indexableContent joins with
}

A kind is searchable iff its class implements this and is wired into EventFactory (the stores probe by kind). The set is nip50Search/SearchableKinds.kt ALL — 201 kinds today, one row each in references/searchable-kinds.md with the exact indexableContent() expression.

Implementation rules:

  • Plain text out: human fields joined with "\n" (a few metadata kinds use " " and override indexableSeparator). JSON-content kinds (kind 0, stalls, channel info) parse first and join the fields, never the raw JSON. Markdown goes in raw.
  • Never throw, never null — a throw aborts the insert transaction. Use ?.let { … } ?: "".
  • Public data only: never index NIP-44 payloads or private tags.
  • Kinds that join several fields should override forEachIndexableField to visit the same fields in the same order (the golden test rejoins them and compares).
  • Eligibility: human-authored text (titles, bodies, names, descriptions). Reactions, follow lists, relay lists and zaps (minus the receipt's comment) stay out.

MANDATORY when you add/remove SearchableEvent or change an indexableContent() body:

  1. Update references/searchable-kinds.md in the same PR — external search engines mirror it at pin bumps.
  2. List the kind in SearchableKinds.ALL (SearchableKindsTest sweeps kinds 0–65535 through EventFactory and fails otherwise) and register the class in EventFactory, or FullTextSearchModule.isSearchableKind and the reindex scan never see it.
  3. Regenerate quartz/src/jvmTest/resources/indexable-content.golden (IndexableContentGoldenTest).
  4. Existing databases don't reindex themselves: old rows keep their text until IEventStore.reindexFullTextSearch() runs (its KDoc is the contract); app-side, schedule the resumable overload after shipping.

Surprises: kind 9735 indexes the embedded zap request's comment; kinds 0/31990 index many profile fields space-joined; kind 30063 indexes release notes only for NIP-82 releases (NIP-51 content may be encrypted); poll kinds append each option label.

How the stores index

  • SQLite (FullTextSearchModule.kt): contentless FTS5 table event_fts, rowid = event_headers.row_id, kept in sync by a delete trigger; default unicode61 tokenizer; queries are always a bound event_fts MATCH ?, ordered by bm25 rank then created_at DESC (rules S01–S06).
  • Filesystem (jvmMain/.../store/fs/FsIndexer.kt, FsSearchTokenizer.kt): tokenizes indexableContent() itself, approximating unicode61; the same tokenizer runs on queries.

NIP-50 client side

  • nip50Search/SearchQuery.kt — SearchQuery.parse(search) splits terms from extensions. A token is an extension iff it looks like lowercasekey:value with the value not starting with // (URLs stay text); duplicate keys keep the last; unknown keys are kept (extension(key)). Typed: includeSpam, domain, language, sentiment, nsfw. stripExtensions() / Filter.strippingSearchExtensions() is what the built-in stores apply: unsupported extensions are ignored, so an extensions-only search is unconstrained. A store that implements extensions gets the raw string and should parse it with SearchQuery.parse.
  • SearchRelayListEvent (nip50Search/SearchRelayListEvent.kt) — kind 10007, the user's search relays (public + NIP-44 private tags; not itself searchable). Used by commons/.../commons/actions/SearchActions.kt; defaults in commons/.../commons/account/AccountBootstrapEvents.kt.
  • Not the same thing: commons/.../commons/search/SearchQuery.kt is an app-level local-feed query model.

Search tests: commonTest/.../nip50Search/SearchQueryTest.kt, SearchableKindsTest.kt, jvmTest/.../nip50Search/IndexableContentGoldenTest.kt, the sqlite SearchTest / SearchRelevanceOrderTest / NoFullTextSearchTest, and jvmTest/.../store/fs/FsSearchTest.kt.

For parity implementers

Cite rule ids in divergence notes ("diverges from STORE-D05: we no-op deletion-of-a-deletion"). F06 (hash-based tags) and F08 (unordered same-second ties) are where the reference itself has slack, so a byte-for-byte diff there may not be your bug.