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>
9.1 KiB
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/untilinclusive · 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#dmatched onevent_headers.d_tag, use it only via plaintags· F06 tag matching is 64-bit hash, no post-verify · F07 multi-filter = UNION, limit per filter, no global limit · F08created_at DESC; same-second order unspecified unlessuseAndIndexIdOnOrderBy· F09 merge fast path returns the same set · F10 empty filter matches all, butdelete(Filter())deletes nothing · F11 empty lists are invalid input · F12limit = 0→ none · F13FilterMatcheris 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 =precipient · 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
MATCHgrammar · 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 overrideindexableSeparator). 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
forEachIndexableFieldto 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:
- Update
references/searchable-kinds.mdin the same PR — external search engines mirror it at pin bumps. - List the kind in
SearchableKinds.ALL(SearchableKindsTestsweeps kinds 0–65535 throughEventFactoryand fails otherwise) and register the class inEventFactory, orFullTextSearchModule.isSearchableKindand the reindex scan never see it. - Regenerate
quartz/src/jvmTest/resources/indexable-content.golden(IndexableContentGoldenTest). - 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 tableevent_fts,rowid=event_headers.row_id, kept in sync by a delete trigger; defaultunicode61tokenizer; queries are always a boundevent_fts MATCH ?, ordered by bm25rankthencreated_at DESC(rules S01–S06). - Filesystem (
jvmMain/.../store/fs/FsIndexer.kt,FsSearchTokenizer.kt): tokenizesindexableContent()itself, approximatingunicode61; the same tokenizer runs on queries.
NIP-50 client side
nip50Search/SearchQuery.kt—SearchQuery.parse(search)splitstermsfromextensions. A token is an extension iff it looks likelowercasekey:valuewith 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 withSearchQuery.parse.SearchRelayListEvent(nip50Search/SearchRelayListEvent.kt) — kind 10007, the user's search relays (public + NIP-44 private tags; not itself searchable). Used bycommons/.../commons/actions/SearchActions.kt; defaults incommons/.../commons/account/AccountBootstrapEvents.kt.- Not the same thing:
commons/.../commons/search/SearchQuery.ktis 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.