# Research: Expiring Events ## Topic Summary NIP-40 defines an optional `expiration` tag of the form `["expiration", ""]`. 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` 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` wrapper with accessor methods. Following that style: - `get_expiration(tags: &Tags) -> Option` — 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.