Skip to content

🤖 feat(relay): carry the deleted original's slot in deletion notices - #7884

Closed
wpfleger96 wants to merge 1 commit into
mainfrom
hayt/tombstone-b-relay-slot
Closed

wpfleger96 wants to merge 1 commit into
mainfrom
hayt/tombstone-b-relay-slot

Conversation

@wpfleger96

Copy link
Copy Markdown
Member

Stack: this PR → PR 2 (desktop channel projection, to follow) → PR 3 (desktop threads, to follow)

Relay-signed message_deleted notices (kind 40099) now carry the deleted original's structural slot, so clients can render the tombstone where the original lived, including thread replies after a reload. This PR is relay-only; desktop consumption comes in the stacked PRs.

Wire contract

  • content.original (optional, all-or-nothing, version: 1): created_at, parent_event_id, root_event_id, depth, broadcast. No content, author, or tags of the original.
  • Tags: exactly one h, plus unmarked e tags for target, root, and parent (deduplicated). No reply/root markers, no p tags.
  • The notice's own created_at stays the deletion time and is never backdated, so live since-filtered subscribers still receive it.
  • A slot that fails shape validation is dropped and the notice degrades to the legacy form. Clients must honor slot placement only when the notice is signed by the relay's key.

Changes

  • handlers/deletion_tombstone.rs: one build_delete_tombstone used by both emitters, replacing the NIP-29 inline JSON and the test-only delete_tombstone_content duplicate.
  • NIP-29 DELETE_EVENT: soft-delete, thread counters, and notice insert commit in one transaction (soft_delete_event_with_notice); fan-out happens after commit. A failed notice insert rolls the deletion back instead of losing the marker.
  • Admin delete: migration 0050 adds nullable relay_admin_actions.original_slot JSONB. execute_delete_with_marker freezes the slot, or "unavailable" if the row is gone, in the lease-fenced transaction. finalize_success copies it into the tombstone outbox payload, and deliver_tombstone rebuilds from the payload. Retries sign identical bytes even after the target is purged. Actions committed before 0050 freeze their slot once at finalization. Outbox rows without original deliver the legacy notice.

Relay-signed message_deleted notices now record where the original lived
(created_at, parent, root, depth, broadcast) so clients can draw the tombstone
in place. One builder serves both the NIP-29 DELETE_EVENT path and the admin
outbox tombstone.

The NIP-29 soft-delete and its notice now commit in one transaction, so a
failed notice insert can no longer lose the marker. The admin path freezes the
slot on the action row with the mutation marker (migration 0050), and delivery
rebuilds from that payload, so retries sign identical bytes even after purge.
Rows from before the column existed freeze their slot once at finalization.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested: one deletion-recovery defect and two stale migration-test expectations (grouped in the second inline finding).

Merge criteria: make failed NIP-29 deletion recover through the real ingest/same-event retry boundary, cover that failure path, and update the migration fixtures so the PostgreSQL gate passes. Preserve atomic target/counter/notice persistence.

Reviewed d19dbe7652643664c7e7d95e8d233259ae35b103 against 20131488528e35e6c50f4ccdb0490a9135c28edf. Source-only review on Blox, with independent wire/security and NIP-29 lanes; no code executed or tests rerun. Existing CI ran merge f3ed1b9664c58fcb8bbc8372d01e61c7b2d8a521; the new handler/DB tests passed but bypass the failed-command replay boundary. Admin freeze/outbox recovery and legacy wire compatibility produced no blockers. Desktop rendering is explicitly deferred to the stacked PRs.

channel_id,
)
.await
.map_err(|e| anyhow::anyhow!("soft_delete_event failed: {e}"))?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep a failed delete retryable through ingest

If the notice INSERT fails after the target UPDATE, soft_delete_event_with_notice rolls back the deletion and counters, but the kind-9005 command has already committed in ingest.rs:3315-3347. The caller only logs this side-effect error (3361-3368), then dispatches the command and returns accepted: true (3415-3431). Replaying the same signed command returns duplicate: before side effects (3349-3355), leaving the original live indefinitely for that request. BASE committed the target deletion before emitting the notice; this PR newly makes notice-storage failure restore the target without giving that rollback a retry owner.

Keep command acceptance and the target/counter/notice transaction recoverable together: preferably include command persistence in the atomic boundary, or provide durable recovery. If using caller retry, both propagate failure and allow an authorized duplicate to finish an incomplete deletion; changing only the ACK is insufficient. Add an ingest-level regression that fails notice persistence, retries the identical signed 9005 event, and verifies the target, counters and one notice converge. The new DB rollback test directly invokes the DB function again, so it cannot catch this duplicate-command boundary.

.sql
.as_str()
.contains("idx_thread_metadata_window"));
assert_eq!(migrations[49].version, 50);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Advance the existing migration fixtures to include 0050

The new migration is registered and the desired-state schema contains original_slot, but two existing tests still describe the previous schema. admin_schema_parity_between_desired_state_and_migrations runs only through migration 47 (runtime/migration.rs:2268-2271), so its migrated database lacks the column now present in schema.sql. Separately, thread_window_postgres_tests.rs:314 still asserts migration version 49 after running the production migrator, which now returns 50.

Both fail in this head’s PostgreSQL CI job. Update the parity fixture to apply the current migrations and the latest-version assertion. This is stale test setup, not a missing production migration; do not remove the column from the desired-state schema to satisfy the parity failure.

@wpfleger96

Copy link
Copy Markdown
Member Author

Pausing tombstone B for now, so closing this. Leaving the branch in place in case we pick it back up later.

@wpfleger96 wpfleger96 closed this Sep 25, 2026

This branch was successfully deployed

No deployments
codex-review — d19dbe76 Deployed Sep 25, 2026 by wpfleger96 via Run Codex Security Review #5629
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.

2 participants