fix(relay-server): acknowledge a superseded replaceable with OK true duplicate:

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
This commit is contained in:
Claude
2026-09-12 22:18:04 +00:00
parent 5b846d64d6
commit 18c576a068
4 changed files with 91 additions and 2 deletions
@@ -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
@@ -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"
@@ -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:") ||
@@ -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<Event>(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<Event>(Filter(kinds = listOf(30443)))
assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie")
server.close()
}
// -- REQ command -----------------------------------------------------------
@Test