Files
coracle-rust/book/research/expiring-events.md
T
2026-05-20 11:07:12 -07:00

7.9 KiB
Raw Blame History

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 expiration tag from an event's tags
  • A predicate is_expired (and a testable is_expired_at(now) variant)
  • Constructing an expiration tag
  • Attaching free-function accessors to HashedEvent and Event
  • 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 expiration tag 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 first expiration tag and parses its value as a number; caches the parsed value via a Symbol.
  • isExpired(event) compares the cached timestamp to unixNow() (Math.round(Date.now() / 1000)). Missing tag → returns false.
  • setExpirationTimestamp(n) is an event-builder operation that replaces any existing expiration tag with ["expiration", n.toString()].
  • ExpirationManager is an injectable storage-layer component that tracks non-expired events and emits an expired$ observable; an EventStore filters expired events out of queries unless keepExpired: true.
  • Time is wall-clock only (Date.now()).

ndk

  • Minimal. Expiration is handled at the model level (e.g., a P2POrderEvent class has expiration getter/setter); no framework-wide isExpired() method.
  • No storage-layer filtering of expired events.

nostr-gadgets

  • Nothing. Zero references to expiration or NIP-40.

nostrlib (Go)

  • nip40.GetExpiration(tags) Timestamp returns -1 on missing/malformed tag. Plain int64 parse.
  • khatru/expiration.go is 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)Date object (from unix seconds × 1000).
  • isEventExpired(event) compares via Date.now(); missing tag → false.
  • Bonus: waitForExpire(event) / onExpire(event, cb) schedule a setTimeout to 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 and TagKind::Expiration kind.
  • 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 to now().
  • Timestamp is a u64 newtype in seconds. Timestamp::now() uses SystemTime with an unwrap-to-default fallback; no clock trait.
  • Storage feature flag event_expiration: bool in the database trait; currently all backends set it to false, so filtering is application-level.

welshman

  • Repository maintains a separate expired: Map<eventId, timestamp> populated at insert time (parses expiration tag, 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 equals now is 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 (passing now explicitly) 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 — uses SystemTime::now().
  • Methods get_expiration / is_expired / is_expired_at on both HashedEvent and Event, delegating to the free functions.
  • A tag constructor. Two plausible shapes:
    • Tag::expiration(ts: u64) -> Tag — parallels Tag::new but specialized.
    • Just document that callers can write Tag::new("expiration", [ts.to_string()]). The existing pow.rs module builds the nonce tag inline via Tag::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.