183 lines
7.9 KiB
Markdown
183 lines
7.9 KiB
Markdown
# 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.
|