Add expiration chapter
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user