From 18c576a068e6793b6d62018a0d1ace2315f32b4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 22:18:04 +0000 Subject: [PATCH] fix(relay-server): acknowledge a superseded replaceable with OK true duplicate: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second finding from the Marmot headless harness on geode. wn's `keys publish` mints a KeyPackage (kind 30443, same `d` tag) in the same second as the one its bootstrap already published; NIP-01's lowest-id-wins tie keeps the stored one, and the insert of the loser trips the addressable unique index. The store classified that as a rejection carrying SQLite's text — "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey, event_headers.d_tag" — so the relay answered OK false with a reason no client can classify, and MDK filed it as "publish acknowledgement unknown" and retried forever. nostr-rs-relay, which this harness was validated against, does not even attempt the insert when a newer version exists and acknowledges the event as `OK true "duplicate: ..."` (its Duplicate status maps to true). Match that: the replaceable and addressable unique-index failures now classify as RejectionReason.SUPERSEDED, a `duplicate:`-prefixed reason the session already answers with OK true. The stored version is untouched, nothing is fanned out, and the STORE-W01/W02 contract in the event-store-semantics skill is updated to say so. Tests: NostrServerTest covers an older kind-0 re-insert and the same-second kind-30443 tie, asserting the OK true duplicate: reply and that the winner remains the only stored version. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc --- .claude/skills/event-store-semantics/SKILL.md | 7 +- .../quartz/nip01Core/store/RejectionReason.kt | 10 +++ .../store/sqlite/SQLiteEventStore.kt | 12 ++++ .../nip01Core/relay/server/NostrServerTest.kt | 64 +++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md index 1c99e335a9..57a9e9a7d0 100644 --- a/.claude/skills/event-store-semantics/SKILL.md +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason). kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning `created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01 lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored -row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract: -exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked. +row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED` +(`duplicate: a newer version of this replaceable event is already stored`), which the relay +session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same +reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken +by lowest id; older re-inserts blocked but acknowledged as already covered. **STORE-W02 — addressable supersession.** Same as W01 with unique index `(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt index 90e416f230..00d6ccc7dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt @@ -49,6 +49,16 @@ object RejectionReason { // The standard store reasons. const val DUPLICATE = "duplicate: already have this event" + + /** + * A replaceable or addressable event that a stored version already supersedes + * (newer `created_at`, or the same `created_at` and a lower id). Nothing is + * written, and — like [DUPLICATE] — the relay answers `OK true`: NIP-01 keeps + * `duplicate:` as the machine-readable prefix for "already covered", and that + * is what nostr-rs-relay sends here too, so clients that retry on anything + * else (MDK's `wn`) settle instead of re-offering the same event forever. + */ + const val SUPERSEDED = "duplicate: a newer version of this replaceable event is already stored" const val EXPIRED = "blocked: Cannot insert an expired event" const val DELETED = "blocked: a deletion event exists" const val VANISHED = "blocked: a request to vanish event exists" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 080d26f4c4..a56fb390f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -64,6 +64,12 @@ class SQLiteEventStore( companion object { /** SQLite's message for the unique index on `event_headers (id)`. */ private const val DUPLICATE_ID_CONSTRAINT = "UNIQUE constraint failed: event_headers.id" + + /** + * Common prefix of SQLite's messages for `replaceable_idx` (`kind, pubkey`) and + * `addressable_idx` (`kind, pubkey, d_tag`) — both start with these two columns. + */ + private const val SUPERSEDED_CONSTRAINT = "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey" const val DATABASE_VERSION = 5 } @@ -536,6 +542,12 @@ class SQLiteEventStore( if (message.contains(DUPLICATE_ID_CONSTRAINT)) { return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE) } + // The replaceable / addressable unique indexes fire only when the supersession + // trigger found nothing older to delete, i.e. the stored version already wins + // (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`. + if (message.contains(SUPERSEDED_CONSTRAINT)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) + } val refusal = message.contains("blocked:") || message.contains("duplicate:") || diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 39d27b15fc..bde070947f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -149,6 +149,70 @@ class NostrServerTest { server.close() } + /** + * STORE-W01: a replaceable event older than the stored version is not written, + * and the relay acknowledges it the way nostr-rs-relay does — `OK true` with the + * NIP-01 `duplicate:` prefix — rather than leaking the unique-index text as a + * rejection the client would keep retrying. + */ + @Test + fun olderReplaceableIsAcknowledgedAsDuplicateNotRejected() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val newer = testEvent(id = hexId(2), kind = 0, createdAt = 2000L) + val older = testEvent(id = hexId(3), kind = 0, createdAt = 1000L) + c1.insert(newer) + c1.insert(older) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",true,"), "older version must be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains("duplicate:"), "older version must carry the duplicate: prefix") + + val stored = store.query(Filter(kinds = listOf(0))) + assertEquals(listOf(newer.id), stored.map { it.id }, "the newer version stays the only stored one") + + server.close() + } + + /** + * STORE-W02 tie: two addressable events with the same `d` tag and the same + * `created_at` — the lower id wins, the other is acknowledged as superseded. + * This is the exact shape MDK's `wn keys publish` produces when it mints a + * second KeyPackage within the same second as the first. + */ + @Test + fun sameSecondAddressableTieLoserIsAcknowledgedAsDuplicate() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(dispatcher, store) + val collector = MessageCollector() + val c1 = server.connect(collector.sendCallback) + + val dTag = arrayOf(arrayOf("d", "kp")) + val lowerId = testEvent(id = hexId(4), kind = 30443, createdAt = 5000L, tags = dTag) + val higherId = testEvent(id = hexId(5), kind = 30443, createdAt = 5000L, tags = dTag) + c1.insert(lowerId) + c1.insert(higherId) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[1].contains(",true,"), "tie loser must be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains("duplicate:")) + + val stored = store.query(Filter(kinds = listOf(30443))) + assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie") + + server.close() + } + // -- REQ command ----------------------------------------------------------- @Test