diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md index a98f901693..e14608e2fc 100644 --- a/.claude/skills/event-store-semantics/SKILL.md +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -143,11 +143,13 @@ 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 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. +row in place and fails the unique index → rejected with `RejectionReason.REPLACED` +(`replaced: a newer version exists`), which the relay session answers with `OK false`: the +event was not written, and NIP-01's `true` means accepted. Same reply strfry gives +(`false, "replaced: have newer event"`); nostr-rs-relay answers `true, "duplicate:"` instead, +and `RejectionReason.SUPERSEDED`, which did the same, is deprecated. Net contract: exactly one +version stored; newest wins; ties broken by lowest id; older re-inserts blocked and reported as +such. **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 @@ -193,16 +195,15 @@ in input order; OK frames pair by event id, not order. exception text.** `SQLiteEventStore.classifyRowError` rolls the row's savepoint back and then asks the connection (which now shows pre-insert state): id already present → `DUPLICATE`; a stored version that beats this one at the replaceable/addressable coordinate (the exact -complement of the supersession predicate in W01/W02) → `SUPERSEDED`; otherwise `Failed`. +complement of the supersession predicate in W01/W02) → `REPLACED`; otherwise `Failed`. Message text is only a fast path and a fallback for trigger RAISEs (`blocked:`, `not allowed`), which leave no database-visible trace. This matters because the message is driver-specific — the bundled JVM driver writes `UNIQUE constraint failed: event_headers.id`, Android's throws an `android.database.SQLException` with a **null** message — so a text-only classifier answered `OK false` on Android for events the store already held. Corollary: re-offering a stored -replaceable/addressable event **byte-for-byte** is `DUPLICATE`, not `SUPERSEDED` (it violates -both indexes and only the id answer is driver-independent); a stale *different* version is -still `SUPERSEDED`. Both carry the `duplicate:` prefix, so the relay reply is `OK true` either -way. +replaceable/addressable event **byte-for-byte** is `DUPLICATE` (`OK true`: it is stored), not +`REPLACED` (it violates both indexes and only the id answer is driver-independent); a stale +*different* version is `REPLACED` (`OK false`: it is not). --- @@ -338,6 +339,10 @@ non-itemizable cases). Add one line per behavior change, newest first: `YYYY-MM-DD — what changed`. +- 2026-09-27 (pending) W01/W02, W09 — a stale replaceable/addressable version is `REPLACED` + (`replaced:` → `OK false`) again, not `SUPERSEDED` (`duplicate:` → `OK true`): it is not + written, and `OK true` told the client it was. `SUPERSEDED` is deprecated. Byte-for-byte + re-offers stay `DUPLICATE`. - 2026-09-18 (pending) W09, W01/W02 — insert-failure classification now queries the database instead of parsing the driver's exception message (Android's is null, so duplicates were reported as `Failed`/`OK false`). A byte-for-byte re-offer of a stored replaceable/addressable 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 00d6ccc7dc..f9b6378e8b 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 @@ -51,17 +51,30 @@ object RejectionReason { 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. + * No longer produced by any store: a superseded version is [REPLACED]. Its + * `duplicate:` prefix made the relay answer `OK true` for an event that was + * never written, which NIP-01 reserves for an accepted one. */ + @Deprecated( + "A stale replaceable/addressable version is not written, so it is REPLACED (OK false), not a duplicate.", + ReplaceWith("RejectionReason.REPLACED", "com.vitorpamplona.quartz.nip01Core.store.RejectionReason"), + ) 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" + + /** + * A replaceable or addressable event that a stored version already supersedes + * (newer `created_at`, or the same `created_at` and a lower id): STORE-W01/W02. + * Nothing is written, so the relay answers `OK false` — NIP-01's third field is + * `true` only when the event was accepted, and acking it would tell the client + * its event is on this relay when no REQ will ever return it. `replaced:` is + * also strfry's answer (`false, "replaced: have newer event"`), and it does not + * misname the case the way `duplicate:` would: the relay does not have THIS + * event, it has a newer one. A client should not retry it; nothing a retry can + * change. A byte-for-byte re-offer of the stored version is [DUPLICATE] instead. + */ const val REPLACED = "replaced: a newer version exists" const val INSERT_FAILED = "error: insert failed" } 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 9f267c3d6c..a9caa7a1fe 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 @@ -581,9 +581,9 @@ class SQLiteEventStore( } // 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`. + // (STORE-W01/W02). Nothing was written, so REPLACED: `OK false`, not a duplicate. if (message.contains(SUPERSEDED_CONSTRAINT) || isSupersededByStored(event, db)) { - return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) + return IEventStore.InsertOutcome.Rejected(RejectionReason.REPLACED) } return if (message.contains("constraint", ignoreCase = true)) { @@ -618,7 +618,7 @@ class SQLiteEventStore( * trigger *would* have deleted doesn't count. That precision matters * here: this is the fallback for unrecognized failures, and a disk * error while inserting a winning replaceable event must stay `Failed` - * rather than turn into a silent `OK true`. An equal id is the + * rather than be reported as having lost to a stored version. An equal id is the * duplicate case and is answered before this one. */ private fun isSupersededByStored( 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 bde070947f..a45654e7f2 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 @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.utils.EventFactory import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -151,12 +152,12 @@ class NostrServerTest { /** * 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. + * so the relay answers `OK false` with `replaced:` (strfry's answer) — never + * `OK true`, which NIP-01 keeps for an accepted event, and never the raw + * unique-index text, which no client can classify. */ @Test - fun olderReplaceableIsAcknowledgedAsDuplicateNotRejected() = + fun olderReplaceableIsRejectedAsReplaced() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) @@ -172,8 +173,8 @@ class NostrServerTest { 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") + assertTrue(okMessages[1].contains(",false,"), "older version was not stored, so it must not be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains(RejectionReason.PREFIX_REPLACED), "older version must carry the replaced: 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") @@ -183,12 +184,13 @@ class NostrServerTest { /** * 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. + * `created_at` — the lower id wins, the other is rejected as `replaced:`. This + * is the shape MDK's `wn keys publish` produces when it mints a second + * KeyPackage within the same second as the first; the loser is not stored, + * so the client must learn that rather than be told it was accepted. */ @Test - fun sameSecondAddressableTieLoserIsAcknowledgedAsDuplicate() = + fun sameSecondAddressableTieLoserIsRejectedAsReplaced() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) @@ -204,8 +206,8 @@ class NostrServerTest { 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:")) + assertTrue(okMessages[1].contains(",false,"), "tie loser was not stored, so it must not be acked, got ${okMessages[1]}") + assertTrue(okMessages[1].contains(RejectionReason.PREFIX_REPLACED)) val stored = store.query(Filter(kinds = listOf(30443))) assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt index 2bdcba4dc0..a731bb4d61 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt @@ -34,9 +34,9 @@ import kotlin.test.assertTrue /** * `batchInsert` must name *why* a row didn't land, because the relay turns - * that reason into the NIP-01 answer: [RejectionReason.DUPLICATE] and - * [RejectionReason.SUPERSEDED] are `OK true` ("already covered"), everything - * else is `OK false`, which clients retry. + * that reason into the NIP-01 answer: [RejectionReason.DUPLICATE] is `OK true` + * (the event is already here), everything else is `OK false` — including + * [RejectionReason.REPLACED], a stale version that was not written. * * This suite pins the classification **independently of the SQLite driver's * exception text**. The bundled JVM driver raises @@ -81,18 +81,18 @@ class InsertOutcomeClassificationTest : BaseDBTest() { } @Test - fun olderReplaceableIsRejectedAsSuperseded() = + fun olderReplaceableIsRejectedAsReplaced() = forEachDB { db -> val time = TimeUtils.now() val older = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) val newer = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) - assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(older))[0]) + assertRejected(RejectionReason.REPLACED, db.batchInsert(listOf(older))[0]) } @Test - fun sameSecondReplaceableTieLoserIsRejectedAsSuperseded() = + fun sameSecondReplaceableTieLoserIsRejectedAsReplaced() = forEachDB { db -> val time = TimeUtils.now() // NIP-01 breaks a created_at tie by lowest id, so the winner is @@ -104,18 +104,18 @@ class InsertOutcomeClassificationTest : BaseDBTest() { ).sortedBy { it.id } assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(winner))[0]) - assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(loser))[0]) + assertRejected(RejectionReason.REPLACED, db.batchInsert(listOf(loser))[0]) } @Test - fun olderAddressableIsRejectedAsSuperseded() = + fun olderAddressableIsRejectedAsReplaced() = forEachDB { db -> val time = TimeUtils.now() val older = signer.sign(LongFormContentEvent.build("v1", "title", dTag = "blog", createdAt = time)) val newer = signer.sign(LongFormContentEvent.build("v2", "title", dTag = "blog", createdAt = time + 1)) assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) - assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(older))[0]) + assertRejected(RejectionReason.REPLACED, db.batchInsert(listOf(older))[0]) } @Test