← All docs  Β·  Home

ADR 0017 β€” Negentropy (NIP-77) range-based sync for backdated reconciliation

Context

The advanced-client keeps a local copy of a target's feedback and pulls incrementally with a resume cursor: /sync?gt_iat=<cursor> returns only items strictly newer than the cursor. This is O(new) for forward arrivals, but it is structurally blind to backdated items β€” an annotation whose iat is below the cursor (a late federation hop, a clock-skewed peer, an import of old data) is never re-seen. M7 patched the hole with reconcile_full: a from-scratch gt_iat = 0 pull that re-fetches the entire target set. Correct, but O(all) on every reconcile β€” untenable as a set grows.

Nostr's NIP-77 "negentropy" solves exactly this: efficient range-based set reconciliation that transfers data proportional to the size of the difference between two sets, not their total size.

Decision

Implement negentropy over the per-(server, target) set of content-addressed dedup ids, and make it the reconcile path; keep reconcile_full as a labeled fallback.

The algorithm (protocol-lib::negentropy, pure Rust, native + wasm)

Both peers hold a set of Item { timestamp, id } (the annotation's iat and its dedup id) and sort it by (timestamp, id) β€” the NIP-77 sort key β€” so both derive the same canonical order and address the same ranges. Then:

  1. The initiator sends a covering set of ranges. For each range it sends either a fingerprint (a cheap digest of the ids in that range) or, when the range already holds few ids, the explicit id list (NIP-77's IdList mode).
  2. The responder compares each range against its own set. A matching fingerprint settles the range β€” nothing transfers. A mismatch is split into up to BUCKETS sub-ranges (at real item boundaries) that recurse; once a range is small (≀ ID_LIST_THRESHOLD) it answers with explicit ids.
  3. The initiator diffs each settled id list into have (only it holds) and need (only the peer holds), and re-poses still-mismatching fingerprint ranges for the next round. Recursion depth is log_BUCKETS(N).
  4. The initiator fetches only the need ids in bulk.

Framing β€” our choice (we do not match NIP-77's wire bytes)

NIP-77 is a binary, varint-packed, stateful streaming protocol built for a relay's persistent connection. Freedback is HTTP/1.1 batch, not real-time (INVARIANT 7), so we keep the negentropy algorithm but reframe each round as a stateless JSON request/response:

Where each side lives

Why these specifics

Consequences