9.3 KiB
Kinds
Every event carries a kind field — a 16-bit integer that says what the
event means. Kind 0 is a profile. Kind 1 is a short text note. Kind 5 is
a deletion request. Kind 1984 is a content report. The number is the only
thing that distinguishes a follow list from a zap receipt; the rest of the
event structure is identical.
Using integers instead of human-readable names is deliberate. In a protocol
built on signed, immutable data, names can never change once chosen. Numbers
carry no inherent meaning, which lets every implementer assign their own
terminology based on their understanding of a kind's purpose. The protocol
says "kind 0"; whether your library calls that Metadata, Profile, or
UserInfo is your business.
This chapter does not catalog individual kinds — a future series of chapters will do that. Instead it builds the infrastructure that those chapters will use: a trait for attaching behavior to a kind number, free functions for classifying raw kind values, and a concrete example to prove the pattern works.
The module
pub mod kinds;
//! Event kind classification and the [`Kind`] trait for attaching
//! domain-specific behavior to kind numbers.
use std::fmt;
use crate::events::{Event, EventContent, EventTemplate};
Classifying a kind number
The nostr protocol divides the 16-bit kind space into four ranges, each with different storage and replacement semantics:
| Range | Name | Behavior |
|---|---|---|
| 0–9999 | Regular | Stored by relays, no replacement logic |
| 10000–19999 | Replaceable | Relay keeps only the latest event per author and kind |
| 20000–29999 | Ephemeral | Relays broadcast but do not store |
| 30000–39999 | Addressable | Like replaceable, but partitioned by a d tag |
Two exceptions break the pattern: kinds 0 and 3 sit below 10000 but are replaceable. A user's profile (kind 0) and follow list (kind 3) predate the range system and were grandfathered in.
These four functions encode the rules so that the rest of the library doesn't have to remember them:
/// A regular event: stored by relays with no replacement logic.
pub fn is_regular(kind: u16) -> bool {
kind != 0 && kind != 3 && kind < 10_000
}
/// A replaceable event: relays keep only the latest per (author, kind).
/// Kinds 0 and 3 are replaceable despite falling below 10000.
pub fn is_replaceable(kind: u16) -> bool {
kind == 0 || kind == 3 || (10_000 <= kind && kind < 20_000)
}
/// An ephemeral event: relays broadcast it but do not store it.
pub fn is_ephemeral(kind: u16) -> bool {
20_000 <= kind && kind < 30_000
}
/// An addressable event: like replaceable, but the relay partitions by
/// the event's `d` tag in addition to author and kind.
pub fn is_addressable(kind: u16) -> bool {
30_000 <= kind && kind < 40_000
}
These are free functions rather than methods on a type because plenty of
code needs to classify a raw u16 without having a Kind implementor
in hand. Relay logic, filter construction, and storage layers all work
with kind numbers directly.
Errors
Parsing an event into a kind-specific type can fail for two reasons: the event's kind doesn't match what the parser expects, or the content and tags don't conform to the kind's format.
/// Errors that can occur when parsing an event into a kind-specific
/// domain type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KindError {
/// The event's kind field does not match the expected kind number.
WrongKind {
expected: u16,
got: u16,
},
/// The event's content or tags could not be parsed for this kind.
InvalidContent(String),
}
impl fmt::Display for KindError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KindError::WrongKind { expected, got } => {
write!(f, "expected kind {expected}, got {got}")
}
KindError::InvalidContent(msg) => {
write!(f, "invalid content for kind: {msg}")
}
}
}
}
impl std::error::Error for KindError {}
The Kind trait
A kind is more than a number — it's a contract about what an event's
content and tags mean and how to work with them. The Kind trait captures
that contract. Implement it for a domain type and you get:
- A kind number constant that ties the type to a specific integer, plus
a
kind()method to access it from an instance. - A
from_evententry point that checks the kind and delegates to your parser. - A
to_templatemethod that serializes your type back into an event.
The set of kinds is open-ended — every NIP can introduce new ones — so a trait is the right abstraction. Each module that handles a specific kind implements the trait for its own type without touching a central enum or registry.
/// A domain type that corresponds to a specific event kind.
///
/// Implementors provide a kind number, a parser that reads an event's
/// content and tags into the domain type, and a serializer that
/// converts it back into an event template. The trait provides a
/// `from_event` entry point that checks the kind number before parsing.
pub trait Kind: Sized {
/// The kind number this type handles.
const KIND: u16;
/// Parse an event's content and tags into this domain type.
///
/// This is called by [`from_event`](Kind::from_event) after the kind
/// number check passes, so implementations can assume the kind is
/// correct.
fn parse(event: &Event) -> Result<Self, KindError>;
/// Convert this domain type into an event's content and tags.
fn to_content(&self) -> EventContent;
/// The kind number for this instance.
fn kind(&self) -> u16 {
Self::KIND
}
/// Convert this domain type into an event template.
///
/// The default implementation calls [`to_content`](Kind::to_content)
/// and attaches the kind number. Override this only if you need
/// non-standard behavior.
fn to_template(&self) -> EventTemplate {
self.to_content().kind(Self::KIND)
}
/// Parse an event into this domain type, checking the kind number
/// first.
fn from_event(event: &Event) -> Result<Self, KindError> {
if event.kind != Self::KIND {
return Err(KindError::WrongKind {
expected: Self::KIND,
got: event.kind,
});
}
Self::parse(event)
}
}
The trait has one associated constant and two required methods.
Implementors provide parse (event to domain type) and to_content
(domain type to event content). The trait provides kind() for
instance access, to_template() to attach the kind number, and
from_event() to check the kind before parsing.
Classification lives in the free functions from earlier in the chapter.
Given any Kind implementor, is_replaceable(deletion.kind()) tells
you what you need to know.
The to_content / to_template split keeps implementors from having to
repeat the kind number. to_content returns the content and tags;
to_template attaches the kind. Since the kind is already known from
the constant, there's no reason to ask every implementor to specify it
again.
A concrete example
Kind 5 is a deletion request. Its content is an optional human-readable
reason, and its tags reference the events or addresses being deleted. It
makes a good first implementation of the Kind trait: simple enough to
fit in a few lines, real enough to exercise every part of the interface.
use coracle_lib::kinds::{Kind, KindError};
use coracle_lib::events::{Event, EventContent};
use coracle_lib::tags::Tags;
/// A deletion request (kind 5). The content is an optional reason
/// string, and the tags reference the events or addresses to delete.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Deletion {
/// An optional human-readable reason for the deletion.
pub reason: String,
/// The tags from the original event, which reference the events
/// or addresses being deleted.
pub tags: Tags,
}
impl Kind for Deletion {
const KIND: u16 = 5;
fn parse(event: &Event) -> Result<Self, KindError> {
Ok(Deletion {
reason: event.content.clone(),
tags: event.tags.clone(),
})
}
fn to_content(&self) -> EventContent {
EventContent::new(self.reason.clone(), self.tags.clone())
}
}
With this implementation, parsing a deletion event is:
let deletion = Deletion::from_event(&event)?;
And creating an event template from a Deletion value is:
let template = deletion.to_template();
assert_eq!(template.kind, 5);
Classification uses the free functions with the instance's kind number:
use coracle_lib::kinds;
assert!(kinds::is_regular(deletion.kind()));
assert!(!kinds::is_replaceable(deletion.kind()));
Later chapters will follow this same pattern for profiles, follow lists,
reactions, zaps, and everything else. The shape is always the same:
define a struct, implement Kind, and the trait provides the rest.
What's next
The next chapter looks more closely at kind ranges — the system that ties storage behavior to kind numbers — and what that means for relay queries and event addressing.