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

183 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.