Skip to content

Bound NIP-RS retention and search indexing#1771

Merged
tlongwell-block merged 13 commits into
mainfrom
wren/niprs-retention-search
Jul 13, 2026
Merged

Bound NIP-RS retention and search indexing#1771
tlongwell-block merged 13 commits into
mainfrom
wren/niprs-retention-search

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • bound storage for conforming NIP-RS read-state events (kind 30078, exactly one d=read-state:<32 lowercase hex> tag, and exactly one exact two-element t=read-state tag) by hard-deleting superseded payloads instead of retaining soft-deleted history
  • preserve NIP-33 ordering and stale-replay protection in a compact per-coordinate watermark, including after a later NIP-09 deletion removes the live head
  • migrate existing data safely by seeding watermarks first and physically purging only superseded NIP-RS rows that have a strictly dominating live head
  • reduce future full-text-search amplification with a positive search_tsv allowlist for kinds 0, 9, 40002, 45001, and 45003
  • give fresh empty databases that allowlist automatically, while leaving populated databases on an explicit out-of-band rewrite path

Runtime behavior

For conforming NIP-RS coordinates—requiring exactly one d tag and exactly one exact two-element t=read-state tag—replacement remains serialized by the existing per-coordinate advisory lock. The database trigger advances/checks the durable watermark on every insert—including inserts from pre-PR relay binaries during a rolling deploy—while the application checks both the live event and watermark. An older tuple is rejected (lowest event ID wins equal timestamps), and superseded payloads plus defensive mention rows are physically removed. Legacy soft-delete and NIP-09 paths are also converted to physical deletion by a database trigger, while retaining only the compact ordering watermark.

Other kind-30078 application data and all other NIP-33 kinds keep the existing soft-delete replacement behavior. channel_id remains outside the NIP-33 replacement key.

Deployment behavior

With BUZZ_AUTO_MIGRATE enabled, migration 0007 takes SHARE ROW EXCLUSIVE on events, so reads remain available while event inserts, updates, and deletes wait for commit. It:

  1. creates parameterized_event_watermarks and the mention-cleanup index;
  2. fails closed if legacy data contains a deleted conforming NIP-RS tuple that outranks its live head;
  3. seeds each coordinate's greatest accepted tuple across live and historical rows; and
  4. physically deletes only superseded NIP-RS history with a strictly dominating live head.

Migrations 0007 and 0009 are checksum-frozen at their published contents. Additive migration 0009 installs the database triggers that enforce watermark advancement/stale rejection and physical NIP-RS deletion across old and new relay binaries, plus the live-event fence for post-commit mention indexing. Exact duplicate idempotence is enforced at the durable coordinate level: an incoming tuple exactly equal to the watermark is skipped by the insert trigger and reports zero affected rows whether its payload is live or already deleted, eliminating replay/deletion resurrection races without another lock edge. Event replacement and legacy soft-delete cleanup both use the same event-row-before-mentions lock order. Additive migration 0010 replaces only the watermark guard function to make exact equality a zero-row no-op. Additive migration 0011 then tightens every database-side classifier to exact tag cardinality and installs a fail-closed hard-delete fence for mixed-version writers; corrected writers opt in transaction-locally, while old writers attempting an unsafe delete raise and roll back the whole replacement. Databases that already recorded migrations 0001–0010 upgrade directly to 0011 without a sqlx checksum mismatch, and migrations 0007–0010 remain byte-for-byte frozen.

Rows deleted explicitly through NIP-09 and lacking a live head are not purged by 0007. The migration does not rewrite search_tsv or rebuild its GIN index.

Migration 0008 serializes an emptiness check with event writers. A fresh empty database switches to the positive FTS allowlist. A populated database records 0008 as applied without changing its existing denylist expression or rewriting heap/TOAST/GIN storage. Search behavior therefore intentionally differs between fresh and existing installations until maintenance is performed.

Optional maintenance to convert populated databases

The PR is boot-safe without this step: populated databases keep their existing search expression. To convert one to the positive allowlist, run scripts/maintenance/nip_rs_search_allowlist.sql separately in a sized maintenance window. It takes ACCESS EXCLUSIVE, so event reads and writes block; it rewrites the generated search_tsv column and rebuilds the GIN index. Confirm headroom for replacement heap/TOAST/index files plus WAL before running it.

Migration 0007's in-place hard deletes do not by themselves return filesystem space to the volume. The allowlist maintenance script rewrites the generated column and rebuilds the GIN index, replacing those relations, but it needs peak headroom while old and new files coexist and release can be delayed by old snapshots. If cleanup is run without that rewrite—or measured residual bloat remains afterward—use a table repack or VACUUM FULL in a separately sized maintenance window.

Validation

  • just ci
  • cargo test -p buzz-db
  • cargo check -p buzz-db
  • Postgres regressions proving replacement hard-deletes the old payload and the watermark rejects replay after simulated NIP-09 deletion
  • mixed-version A → old writer B → NIP-09 delete B → replay B and X (A < X < B) regression proving the database advances to B, physically removes old payloads, fences stale post-commit mentions without deadlock, and rejects both exact and intermediate replay
  • fresh 0001→0008 migration proving the installed catalog expression is the positive allowlist
  • populated-at-0007 migration proving 0008 preserves the legacy expression and leaves all 99 events-family relation file nodes unchanged
  • fail-closed rollback test for a deleted tuple outranking its live head
  • exact migration/runtime NIP-RS predicate tests covering duplicate and malformed d/t tags, including legacy SQL writes
  • fail-closed mixed-version hard-delete regression proving the prior payload and mention survive rollback and transaction-local authorization clears after both commit and rollback
  • authentic pre-0007 ambiguity regression proving versions and source data remain unchanged on failure, then migration 0001–0011 succeeds after operator repair
  • explicit upgrades from databases with published migrations recorded through 0008, 0009, and 0010 to their additive successors without checksum mismatch
  • all 18 Postgres search integration tests against migrations 0001–0009, including a non-vacuous soft-deletion exclusion tripwire
  • after merging current origin/main as a merge commit, Hermit-activated pre-push hooks, 79 non-Postgres buzz-db tests, fresh 0001–0011 migration, authentic pre-0007 fail-closed/retry, exact-cardinality, mixed-version replay/lock, and hard-delete fence/GUC regressions are green at 882367357ad29820eb7c4c604991d62b6ebe0bec; GitHub CI pending

Persist compact ordering watermarks for read-state coordinates, hard-delete superseded payloads, purge safely dominated history, and restrict stored full-text vectors to product-searchable kinds.

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block tlongwell-block requested a review from a team as a code owner July 12, 2026 15:32
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 added 12 commits July 12, 2026 13:27
Move the full search-vector rewrite to an explicit maintenance script, index defensive mention cleanup, fail closed on anomalous replay ordering, and align migration tag matching with runtime.

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Move watermark advancement, stale replay rejection, legacy soft-delete purging, and post-commit mention fencing into database triggers so migration 0007 is safe during mixed-version relay rollouts.

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block tlongwell-block merged commit 1b47030 into main Jul 13, 2026
70 of 75 checks passed
@tlongwell-block tlongwell-block deleted the wren/niprs-retention-search branch July 13, 2026 21:58
wpfleger96 added a commit that referenced this pull request Jul 14, 2026
NIP-37 expiration tags were validated at ingest then discarded — expired
drafts were served and counted indefinitely. This addresses R3 from Tyler's
team's retention review.

Adds draft_expired(event, now) to reader_can_receive_event: a KIND_DRAFT
event is suppressed when now >= min(expiration_tag, created_at + 30d).
Tombstones follow the same rule. Non-draft kinds are unaffected.

The COUNT fast-path (author_is_self SQL count_events()) cannot evaluate
per-event expiry, so filter_can_match_draft forces draft-matching COUNT
filters to the per-event fallback on all four fast-path sites (count.rs
×2, bridge.rs ×2). KIND_EVENT_REMINDER retains its fast-path — reminders
carry no expiry semantics.

All changes are rebase-safe against #1771: nothing in replace_parameterized_event
or schema is touched. NIP-40 advertisement stays absent until the physical
delete reaper (#1771 coupling) lands.
wpfleger96 added a commit that referenced this pull request Jul 14, 2026
NIP-37 expiration tags were validated at ingest then discarded — expired
drafts were served and counted indefinitely. This addresses R3 from Tyler's
team's retention review.

Adds draft_expired(event, now) to reader_can_receive_event: a KIND_DRAFT
event is suppressed when now >= min(expiration_tag, created_at + 30d).
Tombstones follow the same rule. Non-draft kinds are unaffected.

The COUNT fast-path (author_is_self SQL count_events()) cannot evaluate
per-event expiry, so filter_can_match_draft forces draft-matching COUNT
filters to the per-event fallback on all four fast-path sites (count.rs
×2, bridge.rs ×2). KIND_EVENT_REMINDER retains its fast-path — reminders
carry no expiry semantics.

All changes are rebase-safe against #1771: nothing in replace_parameterized_event
or schema is touched. NIP-40 advertisement stays absent until the physical
delete reaper (#1771 coupling) lands.
wpfleger96 added a commit that referenced this pull request Jul 14, 2026
The brownfield migration path — where 0012 DROP + re-ADDs search_tsv
on populated production databases still on the pre-#1771 negative
blocklist — was exercised by zero tests. The fresh-install (allowlist)
path always no-oped via setup(), and migration.rs only string-matched
the SQL text.

Add two integration tests:

- migration_0012_rewrites_legacy_blocklist_to_exclude_drafts:
  constructs the legacy state (0001–0007 → seed row → 0008 skips →
  0009–0011), seeds kind:9 control + kind:31234 draft, applies 0012,
  and asserts the rewrite took effect: expression contains 31234, GIN
  index rebuilt, control row searchable, draft row's search_tsv NULL,
  discriminator correctly classifies the post-rewrite expression as
  blocklist-shaped.

- migration_0012_noops_on_fresh_install_allowlist: verifies the
  fresh-install expression has the ELSE NULL shape that 0012's
  discriminator detects.

Also applies migrations 0009–0011 in setup() so the test chain
matches production exactly (previously omitted).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant