7.9 KiB
Research: Expiring Events
Topic Summary
NIP-40 defines an optional expiration tag of the form
["expiration", "<unix_timestamp_seconds>"]. When present, it marks the
event as no longer valid after the given time. Relays SHOULD stop
serving expired events and MAY drop them from storage. Clients SHOULD
not display expired events. Expiration is a hint, not a cryptographic
guarantee — anyone who already has a copy can keep it.
This chapter should cover:
- Reading the
expirationtag from an event's tags - A predicate
is_expired(and a testableis_expired_at(now)variant) - Constructing an expiration tag
- Attaching free-function accessors to
HashedEventandEvent - Handling missing and malformed tags (treated as never-expires)
Mining, storage filtering, and relay-side enforcement are out of scope — the chapter stays at the tag-parsing layer.
Philosophy
From ref/building-nostr:
- The
expirationtag is a behavior tag — orthogonal to kind, applicable to any event. It requests special handling by relays rather than encoding meaning in the payload. - Expiration is a superset of ephemeral-kind (2XXXX) functionality. Putting retention policy in a tag frees it from the kind range.
- Expiration, like deletion (kind 5), is a request, not a guarantee: "once it's published, there's no way to un-sign or un-publish". Clients and relays cooperate voluntarily.
- Different content has different natural lifespans. Expiration is a design choice reflecting use case — chat messages can expire; wiki entries shouldn't.
The framing matters for the chapter: we are not building a deletion mechanism. We are parsing an opt-in hint that downstream layers (storage, UI, relay policy) may act on.
Reference Implementation Analysis
applesauce
getExpirationTimestamp(event)reads the firstexpirationtag and parses its value as a number; caches the parsed value via a Symbol.isExpired(event)compares the cached timestamp tounixNow()(Math.round(Date.now() / 1000)). Missing tag → returnsfalse.setExpirationTimestamp(n)is an event-builder operation that replaces any existing expiration tag with["expiration", n.toString()].ExpirationManageris an injectable storage-layer component that tracks non-expired events and emits anexpired$observable; anEventStorefilters expired events out of queries unlesskeepExpired: true.- Time is wall-clock only (
Date.now()).
ndk
- Minimal. Expiration is handled at the model level (e.g., a
P2POrderEventclass hasexpirationgetter/setter); no framework-wideisExpired()method. - No storage-layer filtering of expired events.
nostr-gadgets
- Nothing. Zero references to
expirationor NIP-40.
nostrlib (Go)
nip40.GetExpiration(tags) Timestampreturns -1 on missing/malformed tag. Plain int64 parse.khatru/expiration.gois a separate opt-in relay-side manager: min-heap of expiring events, periodic sweep (default 1 hour), deletes on expiration. Not used at query time — strictly a background cleaner.- Uses
nostr.Now()=time.Now().Unix(); no injection.
nostr-tools
getExpiration(event)→Dateobject (from unix seconds × 1000).isEventExpired(event)compares viaDate.now(); missing tag → false.- Bonus:
waitForExpire(event)/onExpire(event, cb)schedule asetTimeoutto fire when the event expires — async sugar, not essential to the core API. - No tag constructor.
rust-nostr
The most structured implementation:
- Tag modeled as
TagStandard::Expiration(Timestamp)variant andTagKind::Expirationkind. - Constructor:
Tag::expiration(timestamp: Timestamp) -> Tag. - Query:
Tags::expiration() -> Option<&Timestamp>. - Predicates on
Event:is_expired_at(&self, now: &Timestamp) -> bool— compares strictly with<(expiration equal to now is not yet expired).is_expired(&self) -> bool(std only) — delegates tonow().
Timestampis au64newtype in seconds.Timestamp::now()usesSystemTimewith an unwrap-to-default fallback; no clock trait.- Storage feature flag
event_expiration: boolin the database trait; currently all backends set it tofalse, so filtering is application-level.
welshman
Repositorymaintains a separateexpired: Map<eventId, timestamp>populated at insert time (parsesexpirationtag, skips NaN).isExpired(event)looks up in the map; strict<comparison.- No builder for the tag; no automatic filtering in query paths.
Common Patterns
- Graceful absence. Every implementation treats a missing
expiration tag as "never expires". None returns an error on absent
tags; most surface
Option/null/ a sentinel. - Graceful malformed. Most implementations silently ignore tags whose value doesn't parse. None treats a malformed tag as expired.
- Strict
<comparison. rust-nostr, welshman, and applesauce all use strict-less-than: an event whose expiration equalsnowis still valid. This is the natural reading of "expires at time T" (the event is invalid after T). - Wall-clock time. No implementation injects a clock. Time comes
from
SystemTime::now()/Date.now()/time.Now(). Testable variants (passingnowexplicitly) are common. - Separation of parse from enforcement. Parsing the tag is cheap and always available. Doing something about it (removing expired events from storage, hiding them in UI, sweeping a relay database) is a separate concern layered on top.
- Minimal tag constructor. Rust-nostr has one; most TS libraries expect the caller to build the tag by hand. Adding one is trivial and makes call sites readable.
Considerations for Our Implementation
Scope. This crate is coracle-lib, which owns nostr types and
stateless utilities. The chapter stays at that layer: tag accessor,
predicate, tag constructor. Storage sweeping and UI hiding belong in
downstream crates (coracle-storage, future application code).
API shape, closely tracking rust-nostr but flatter. The library has
deliberately avoided a TagStandard enum in earlier chapters — Tag
is a thin Vec<String> wrapper with accessor methods. Following that
style:
get_expiration(tags: &Tags) -> Option<u64>— free function.is_expired_at(tags: &Tags, now: u64) -> bool.is_expired(tags: &Tags) -> bool— usesSystemTime::now().- Methods
get_expiration / is_expired / is_expired_aton bothHashedEventandEvent, delegating to the free functions. - A tag constructor. Two plausible shapes:
Tag::expiration(ts: u64) -> Tag— parallelsTag::newbut specialized.- Just document that callers can write
Tag::new("expiration", [ts.to_string()]). The existingpow.rsmodule builds the nonce tag inline viaTag::new, so there is a precedent for the inline form. But an expiration-specific constructor is small and keeps call sites more readable, and there's nothing nonstandard to hide. Lean toward providing it.
Type for timestamps. The rest of the library uses plain u64 for
created_at. Consistency argues for u64 here too; no new
Timestamp newtype.
Comparison semantics. Use strict <. Document it in the rustdoc.
Graceful parsing. Missing tag or unparseable value → None /
false. No errors surfaced to callers.
Time source. Use SystemTime::now().duration_since(UNIX_EPOCH)
directly in is_expired. Keep is_expired_at available as the
testable primitive. No clock trait — it would cost more than it buys
in a library this size.
Dependencies. None beyond what's already pulled in (std::time).
Narrative. The chapter should build on the proof-of-work chapter's structure — free functions plus methods on event types — and lean on the philosophy framing: expiration is a behavior tag, a request, not a cryptographic property of the event. That framing prepares the reader for later chapters where storage and relay layers act on it.