59 KiB
Repository Lifecycle (Deletion, Holding, Archive, and Recovery)
Overview
ngit-grasp owns the lifecycle of repository-related nostr events and git data when a served repository scope is removed, restored, or quarantined. This covers NIP-09 deletion requests, NIP-62 request-to-vanish events, operator blacklist and whitelist reconciliation, service de-listing, holding/archive retention, recovery, and purgatory transitions.
Bounded request retention
Status: Lifecycle metadata, NIP-09 and NIP-62 lifecycle admission (including disrespector read-only would-have-deleted classification), deterministic admission-use attribution, measured destructive outcomes, startup reconciliation, periodic request cleanup/permanent expiry, removal of target-set deduplication, and repair-command retirement are implemented.
Production motivation
Deletion and vanish requests are necessary admission controls, but they can also create substantial retention pressure without hostile traffic. Valid requests published by reputable users may be unrelated to this relay's Git data, and a request can arrive before any event it names. The relay therefore cannot require the target to exist without breaking legitimate out-of-order delivery.
Production measurements exposed the cost of retaining every such request indefinitely: approximately 50,000 of 60,000 stored events were deletion requests. In other words, the relay was receiving and storing roughly five times more deletion requests than all other event kinds combined. Target-coverage deduplication accounted for only about 1,500 requests, leaving approximately 48,000 requests whose retention was not addressed by target-set deduplication. Retaining every accepted request permanently is therefore the wrong storage policy; the relay should retain requests according to demonstrated utility instead.
Bounded lifecycle retention is a storage-hygiene mechanism, not comprehensive adversarial admission control. Trust-based admission, rate controls, and operator moderation are separate concerns and can be layered over this policy.
Intent
The relay should:
- accept valid out-of-order requests without requiring an existing target;
- serve a new request for an intentionally generous probation window that covers delayed rebroadcasting and intermittently connected clients;
- retain an unserved request for a further window in which it can still block a late event;
- retain and serve requests that demonstrably deleted or blocked data;
- eventually expire both the request and its admission-gate effect; and
- permanently remove expired requests and their lifecycle metadata from live storage.
This lifecycle applies to NIP-09 deletion requests and NIP-62 vanish requests. For NIP-62, a request that does not target this relay is never an active local vanish gate, but it still follows the bounded served/unserved retention schedule. In deletion-disrespector mode, NIP-09 and NIP-62 requests likewise follow the storage schedule. A request is classified as used when it would have removed an existing main-database or purgatory event under normal policy, but the relay does not perform that removal or enforce an admission gate. Used requests in this mode remain served indefinitely; unused requests age out normally.
Request lifecycle
Retention is based on a durable relay-observed first_seen_at timestamp, not the
client-controlled Nostr created_at timestamp.
New and unused
- Served probation — 30 days from first receipt: store the original signed request in the main database and serve it normally. Thirty days is intentionally much longer than ordinary Nostr propagation, which is usually measured in hours: the additional time accommodates delayed client rebroadcasting and intermittently connected clients. If processing the request successfully removes at least one event from the main database or purgatory, the request is immediately considered used. Attempted or failed deletions do not count.
- Unserved pending gate — a further 180 days: if still unused after 30 days, remove it from the main database but retain it in the tombstone database. Continue consulting locally actionable requests during admission; retained disrespector and non-targeting NIP-62 records do not enforce a gate.
- Expiry: if it remains unused after the additional 180 days, permanently remove the request and its lifecycle metadata from the tombstone database.
A pending request becomes used only when it actually causes a later event to be rejected under valid ownership, coordinate-cutoff, relay-targeting, and other NIP-09/NIP-62 rules. Merely matching a tag, inspecting the request, or discovering that a different author owns the target does not count as use.
The unserved gate is the deliberate observation period that establishes whether
an apparently unused request still has practical value. While the request was
served, clients that saw it could avoid sending the deleted target to this relay,
even if copies of that target continued circulating elsewhere. Removing the
request from relay queries gives those late copies an opportunity to reach the
relay again. The retained Tombstone gate still rejects a covered target; that
rejection is evidence of current utility, so the winning request becomes used,
is promoted back to Main, and begins the longer used lifecycle. If no covered
target arrives during the unserved period, the request has supplied no evidence
that its gate is still needed and can expire. Replaying the deletion or vanish
request itself is not such evidence and does not update last_used_at.
Used
When a request is used:
- promote it back to the served main database if necessary;
- serve it and enforce its admission gate for 9 months after its last use;
- after 9 months, stop serving it but continue enforcing its gate for a further 3 months; and
- after 12 months without use, expire its gate and permanently remove the request and its lifecycle metadata.
The normal-mode schedule above does not expire used requests in disrespector mode: because archival relays exist to preserve deletion history, a request that would have deleted stored data remains served there indefinitely.
Every successful use resets last_used_at and therefore restarts the 9-month
served plus 3-month unserved lifecycle. Unused retention is based on
first_seen_at; used retention is based on last_used_at. Expiry is intentional:
once both periods end, a previously deleted event, coordinate version, or
vanished-author event is eligible for admission again unless another live
request covers it.
The four lifecycle durations are operator-configurable, with defaults of 30 days for unused serving, a further 180 days for unused gating, 9 months for used serving, and a further 3 months for used gating. Configuration validation must preserve the ordering of each served period followed by its unserved gate period. The existing holding cleanup cadence also schedules deletion-request retention cleanup. Timestamp-derived deadlines determine lifecycle eligibility; physical removal from Main and Tombstones is asynchronous cleanup and may occur on the next scheduled pass. This bounded cleanup delay is intentional and does not extend admission-gate eligibility past the deadline.
Durations use seconds for configuration consistency and to permit short automated tests. Production values are expected to be at least one day and comfortably longer than the worst-case processing time for a deletion or vanish request, including repository archival and cascade work. Sub-day values are a testing facility, not a supported production operating point; cleanup is therefore not coordinated with an initial request handler across an artificially short total lifetime.
Multiple matching requests
If several pending requests would independently reject the same arriving event, promoting all of them would turn one target arrival into retention amplification: an attacker could publish many equivalent requests and make all of them long lived with one later event. Exactly one deterministic sufficient request should receive credit for the use and be promoted. The remaining matching requests keep their existing lifecycle and expire normally.
The winner-selection rule must be deterministic across restarts and independent
of database iteration order. It must also preserve deletion correctness: for an
a-tag target, the selected request must have a cutoff that actually covers the
arriving event. A stable event-ID tie-break should be used after semantic
eligibility and lifecycle priority are considered.
Multi-target requests
Nostr events are signed and cannot be rewritten into a smaller authentic event. If any target makes a request used, retain and promote the whole signed request, including all of its valid targets. This is why the existing maximum target-tag limit remains an important admission bound.
Integration with lifecycle storage
Request retention should extend the existing lifecycle stores rather than add an independent archive hierarchy:
- Main database — served state: contains a copy of each request during its
initial 30-day probation and each normally honored used request within 9
months of
last_used_at. A used request on a disrespector relay remains here indefinitely. Presence in this database determines whether normal relay queries serve the original signed request. - Tombstone database — unserved gate and request lifecycle: contains the
original signed NIP-09/NIP-62 request plus relay-generated lifecycle metadata.
In normal mode it remains the authoritative admission-gate source. It also
retains unused disrespector and non-targeting NIP-62 requests until their
lifecycle expires, although those records do not enforce a local gate.
Disrespector NIP-09 requests are read-only evaluated after persistence and
receive
last_used_atonly when an existing main-database or purgatory target would be removed by normal policy. - Holding database — deleted payload retention: continues to contain events actually removed by NIP-09/NIP-62, with its existing deletion metadata and independent holding-retention clock. Expiry of a request does not shorten or extend holding retention, and expiry of holding data does not change a live request gate.
- Replaceable-history database — rollback state: remains independent and continues to supply valid prior replaceable/addressable versions during a deletion rollback. Request retention must not duplicate this payload history.
No cold request database is added. This matches holding cleanup's existing model: when retention ends, payload and internal metadata are permanently removed.
The Tombstone store should adopt the same payload-plus-internal-metadata-event
pattern already used by Holding and Replaceable History. The original request is
stored verbatim. A relay-generated metadata event, linked to the request with an
e tag and never exposed to clients, records at least first_seen_at, optional
last_used_at, and whether the request is locally actionable, non-targeting, or
handled in disrespector mode.
State transitions must be ordered so failures cannot silently lose a live gate:
- On receipt, persist the request and
first_seen_atmetadata in Tombstones before destructive work or acceptance for main-database storage. - Mark it used only after at least one main-database or purgatory removal has succeeded. A partial deletion counts as use if at least one removal succeeded.
- In disrespector mode, classify it as used when a read-only normal-policy target lookup finds at least one existing main-database or purgatory event that would be removed, without deleting that event, installing a gate, or performing holding, archive, cascade, rollback, or repository work.
- When a later admission is blocked, durably update the deterministic winner's
last_used_atand restore its main-database copy before completing the rejection path. If that update fails, report an internal policy error rather than claiming that retention was extended. - On expiry, remove the main-database copy first, then its Tombstone payload and metadata. If Tombstone cleanup fails, the unserved gate remains conservative and the next cleanup pass retries it.
The Tombstone store should expose one canonical current metadata record per
request. A last_used_at update should save its replacement metadata before
deleting the older metadata record; readers choose the newest valid record during
an interrupted update. Cleanup then compacts stale metadata so frequent reuse
cannot create another unbounded stream.
Deterministic use attribution
Admission lookup may find several sufficient requests. It should first discard requests that do not actually authorize rejection, including wrong-author NIP-09 requests, coordinate deletions with an insufficient cutoff, expired requests, and NIP-62 requests not targeting this relay. From the remaining candidates it should choose exactly one winner using this stable order:
- prefer an already-used request over an unused request, avoiding unnecessary promotion of another payload;
- prefer the earliest relay-observed
first_seen_at; and - use the lowest event ID as the final tie-break.
Only the winner receives a last_used_at update and possible promotion to the
main database. Other sufficient requests still participate in rejection
correctness, but their retention clocks do not change. This rule is independent
of database iteration order and prevents one event arrival from extending an
arbitrary number of duplicate requests.
This deterministic single-winner rule applies when one later target admission is matched against already-pending requests. Initial processing of distinct deletion requests is intentionally less strict: two requests processed concurrently may both observe the same stored target before either removal completes and may both receive use credit. The database deletion API does not report an authoritative per-event removed count, so eliminating that narrow race would require broader target-level serialization. The occasional extra used request is accepted: it is bounded by actual concurrency, does not change deletion correctness, and still expires through the normal used lifecycle.
Simplification of target-set deduplication
The lifecycle replaces semantic target-set deduplication and supersession as the
primary storage-control mechanism. Distinct, valid signed requests receive
independent probation windows even when their target sets overlap. Exact replay
of the same event ID remains an ordinary database duplicate and does not create a
new record or reset first_seen_at.
The live write path therefore does not reject a distinct request merely because another request already covers its targets, and should not delete older requests by trying to prove one signed request semantically subsumes another. Deterministic single-winner attribution provides the necessary anti-amplification bound when a target later arrives, while age-based cleanup handles requests that never become useful. The temporary target-deduplication repair command has been retired; startup migration and lifecycle cleanup own historical request handling.
Once an expired request has been permanently removed, replaying that identical signed event starts a new lifecycle because no live event-ID marker remains.
Migration and policy-mode changes
Existing request rows predate relay-observed lifecycle metadata, so their true
first_seen_at and last_used_at cannot be reconstructed reliably. Migration
assigns the migration timestamp as first_seen_at and gives every historical
request a fresh 30-day probation window. This deliberately favors preservation
over immediate production cleanup; normal cleanup moves requests that remain
unused out of the served main database after that window.
Startup performs this migration before blacklist/whitelist reconciliation and
before the relay starts serving traffic. It discovers signed kind-5 and kind-62
payloads from both the served and tombstone databases, including tombstone
payloads whose metadata is missing or malformed, and deduplicates by signed
event ID. Valid existing metadata is preserved; missing metadata is written
with one startup timestamp and the original payload is promoted to the served
database for its fresh probation. The pass is restart-safe: replacement
metadata preserves the earliest first_seen_at and greatest last_used_at.
Missing lifecycle payloads and metadata are queued in bounded batches so LMDB
can commit many migration writes in one transaction. The same startup snapshot
tracks which requests are already served, avoiding a separate main-database
lookup for every historical request while traffic is still paused.
The same startup pass reclassifies retained requests using current relay configuration and reconciles served copies against the lifecycle deadline. In disrespector mode it read-only evaluates unused targeting requests against main and purgatory data, marking only requests that would currently have an effect as used; it never changes target data during that evaluation. This evaluation includes requests whose unused lifecycle elapsed while the relay was offline: if a matching target is present, startup gives the request use credit before cleanup runs. Used targeting requests remain served in that mode indefinitely, while non-targeting NIP-62 and unused no-op requests retain their ordinary bounded served schedule. Critical query, metadata, promotion, or removal failures fail startup rather than allowing traffic to begin with an incomplete lifecycle reconciliation.
The relay's current deletion-disrespector configuration governs retained requests; receipt-time mode is not permanent metadata. Switching into disrespector mode removes their local gates and keeps requests that are used or would delete currently stored data served indefinitely. Switching back to normal mode reapplies normal gating and the 9-month served plus 3-month unserved expiry schedule using retained lifecycle timestamps. Startup reconciliation must apply these transitions before the relay begins serving traffic.
Startup catch-up and periodic cleanup use the holding-cleanup cadence. They evaluate the same timestamp-derived half-open lifecycle boundaries, remove expired served copies from Main before Tombstones, and permanently remove payload plus lifecycle metadata only after the additional gating period. Main and Tombstone removal can therefore lag a deadline until the next cleanup pass; that is expected cleanup latency, not an extension of the request's gate eligibility. Admission first probes for matching live requests without the lifecycle transition lock, so ordinary unrelated event writes are not serialized behind deletion-store reads. When that probe finds a possible gate, admission acquires the shared transition lock and repeats candidate discovery and winner selection before promotion. This confines serialization to events that actually match a live request while still preventing cleanup from expiring one candidate and causing admission to overlook another live candidate during promotion.
Target-set deduplication has been removed and the temporary
repair-deletion-requests command has been retired. Startup migration and
periodic cleanup now handle historical requests under the same lifecycle rules.
Core consistency invariant
ngit-grasp enforces the following invariant across admission, serving, deletion, and recovery:
Served nostr state must always match git refs we can actually serve.
This invariant is the design center for lifecycle handling and a key rationale for purgatory. If the currently served repository state is removed and no valid rollback state exists, the relay must stop serving that state, park the announcement scope in purgatory, and wait for a promotable state before serving again.
Ownership of lifecycle behavior
ngit-grasp disables backend auto-processing and owns NIP-09/NIP-62 lifecycle handling in relay policy code:
- main DB uses
process_nip09(false)andprocess_nip62(false)(src/nostr/builder.rs) - tombstones DB uses
process_nip09(false)andprocess_nip62(false)(src/nostr/lifecycle/tombstones.rs) - holding DB uses
process_nip09(false)andprocess_nip62(false)(src/nostr/lifecycle/holding.rs)
This keeps lifecycle behavior auditable and prevents the storage backends from silently applying deletion semantics outside ngit-grasp policy code.
The full cascade, holding, archive, and lifecycle flow applies consistently to NIP-09 repository/content deletions, targeted NIP-62 request-to-vanish events, and operator-driven blacklist/whitelist deletions. NIP-62 additionally records a vanish tombstone: archived payloads can remain in holding for retention and cleanup, but the tombstone continues to gate new events from that pubkey unless an explicit tombstone-removal path is introduced.
The Left-Pad Problem
The "left-pad problem" refers to a 2016 incident where a critical npm package was unpublished, breaking thousands of dependent projects. In the context of decentralized Git hosting, this translates to:
Scenario: A popular repository with many PRs, issues, and community contributions gets deleted by its owner. All dependent work (forks, patches, discussions) becomes inaccessible, potentially breaking workflows and losing community knowledge.
Our Solution: The deletion-request-disrespector configuration option allows operators to run archival relays that preserve deleted content, ensuring community work survives repository deletion while still respecting deletion requests on standard relays.
Architecture
Three-Database Design
The repository lifecycle system uses three relay databases plus archive filesystem storage:
┌─────────────────────────────────────────────────────────┐
│ Main Database │
│ (Live events - actively served) │
│ LMDB/Memory backend │
└─────────────────────────────────────────────────────────┘
↓ deletion request
┌─────────────────────────────────────────────────────────┐
│ Tombstones Database │
│ (Kind-5 / Kind-62 requests, deletion gate state) │
│ Derived state: deleted IDs/coords/vanished keys │
└─────────────────────────────────────────────────────────┘
↓ delete acceptance gate
┌─────────────────────────────────────────────────────────┐
│ Holding Database │
│ (Archived events - recovery window) │
│ Same backend type as main │
│ Retention: configurable (default 90 days) │
└─────────────────────────────────────────────────────────┘
↓ expiry
┌─────────────────────────────────────────────────────────┐
│ Permanent Deletion │
│ (Events removed from holding DB) │
└─────────────────────────────────────────────────────────┘
Git Data Flow
┌─────────────────────────────────────────────────────────┐
│ Git Repository (Live) │
│ <git_data_path>/<npub>/<identifier>.git │
└─────────────────────────────────────────────────────────┘
↓ deletion request
┌─────────────────────────────────────────────────────────┐
│ Archive Filesystem │
│ .archive/<npub>/<identifier>-<timestamp>.tar.gz │
│ Metadata is stored as holding DB events/tags │
│ Retention: configurable (default 90 days) │
└─────────────────────────────────────────────────────────┘
↓ expiry
┌─────────────────────────────────────────────────────────┐
│ Permanent Deletion │
│ (Archive files removed) │
└─────────────────────────────────────────────────────────┘
Why Three Stores?
- Main Database: Fast queries, clean data model (deleted = gone)
- Tombstones Database: Durable deletion/vanish state for admission-time gating
- Holding Database: Recovery mechanism, prevents accidental permanent deletion
Archive files on disk complement the DB layers by preserving repository git data during the retention window.
Holding Database Operations
Automatic Operations:
- Move to holding: NIP-09 deletions, targeted NIP-62 vanish removals, blacklist/whitelist deletions
- Automatic recovery: Re-publishing after NIP-09 deletion or operator-driven blacklist/whitelist restoration (within retention window). NIP-62 holding records are retained for safety/cleanup, but recovery of events authored by the vanished pubkey remains blocked by the vanish tombstone.
- Expiry cleanup: Daily background task removes entries older than retention period
Manual Operations:
- Manual ejection: Operator force-deletes before retention expires
- Use case: Large repos consuming excessive storage
- Use case: Confirmed malware requiring immediate permanent deletion
- Mechanism:
ngit-grasp holding-eject --owner <npub|hex> --identifier <id> - Logged for audit trail
- Legacy empty-repository cleanup:
- Mechanism:
ngit-grasp cleanup-empty-repos - Removes empty git repository directories that no longer have useful backing data.
- This is intentionally narrow. A fuller maintenance cleanup command is a planned replacement, not part of the current deletion cascade.
- Mechanism:
Maintenance Cleanup Command (safe vs destructive)
ngit-grasp maintenance cleanup is planned as the replacement for the narrow
legacy cleanup command. Its intended role is a full repository integrity sweep,
not reuse of the live NIP-09 deletion-cascade planner.
The command should analyze the full relay/git data set and compute the state the relay should keep:
- Start from accepted repository announcements.
- Recursively mark repository-related events that remain valid through accepted references.
- Independently retain GRASP-06 PR and PR-update events only when their backing git data exists and is consistent.
- Treat unmarked repository-related events as cleanup candidates.
- Reconcile repository git directories, 30618 state events, PR git data, and PR/PR-update events so served nostr state matches filesystem git data.
The command should be dry-run by default. Mutations should require explicit
--execute, with destructive pruning of non-empty git data and unresolved
missing-git scopes gated by separate opt-in flags and optional age thresholds.
Reports should include candidate event deletions, orphan git directories,
degraded state events, and PR/git mismatches before any destructive cleanup.
Migration from cleanup-empty-repos
cleanup-empty-repos remains the current legacy command. It should not be
treated as a general database/git-data repair mechanism, and it should not grow
ad hoc cascade-reconciliation behavior.
The intended migration is to introduce ngit-grasp maintenance cleanup in a
separate change as the operator-facing replacement. That command should first
ship as a read-only analyzer, then add explicit --execute mutations once the
full-state sweep reports and invariants are well covered. After the replacement
is proven, cleanup-empty-repos can be retained as a compatibility alias or
deprecated in favor of the maintenance command.
- Blacklist restoration policy: optional startup auto-restore
- Controlled by
NGIT_BLACKLIST_AUTO_RESTORE(defaultfalse) - Restores only
holding-source=blacklistscopes that are now unblacklisted and still within holding retention - Still-blacklisted scopes are skipped
- Controlled by
Lifecycle Removal Flows
Standard Mode (Respects Deletions)
NIP-09 Deletion Requests
1. Kind 5 deletion request arrives
↓
2. Validate targets:
- `e` targets found in the main DB must be authored by the deleter;
cross-author main-DB targets reject the whole request
- pre-emptive `e` deletes for unknown targets may be accepted; candidate
lookup later binds the request author to the arriving event author, so a
foreign target does not block admission or receive use credit
- `a` coordinates are acted on only when coordinate pubkey matches deleter
↓
3. Record the signed request and independent lifecycle metadata in Tombstones.
Exact event-ID replay reuses its canonical record without resetting
`first_seen_at`; a distinct signed request is accepted even when targets
overlap.
↓
4. Process targets:
- `a` tag targeting a kind-30617 announcement coordinate:
recursively discover the accepted-reference component affected by the
announcement deletion
- `e` tag targeting an active kind-30618 repository state:
delete the targeted state and attempt rollback from replaceable history;
if no valid active state remains, move the affected announcement scope
through cascade/holding into purgatory
- `e` tag targeting an active kind-30617 repository announcement:
delete the targeted announcement and attempt rollback from replaceable
history; this path does not run generic graph cascade when no history
candidate exists
- other valid `e`/`a` targets: delete the targeted event/coordinate without
announcement graph cascade
↓
5. Archive git repository to .archive/<npub>/<identifier>-<timestamp>.tar.gz
when deleting a repository announcement with live git data
↓
6. Move deleted events to holding database:
- Targeted events
- Repository announcements, when announcement deletion is involved
- All main-DB events that lose their accepted-reference path after cascade
reevaluation, for cascade paths
- Deletion metadata for retention/cleanup/recovery
↓
7. Delete events from main database
↓
8. Remove the live git repository when the owner+identifier announcement scope
is no longer served
↓
9. Deleted/tombstoned targets no longer serve in queries
↓
10. Background task (daily):
- Check holding database for expired entries
- Delete events older than retention period
- Delete corresponding archive files
Deletion processing records a structured outcome: successful main-database
removals and successful purgatory removals are counted separately from skipped
fail-safe preservation and query/delete failures. A kind-5 request receives
last_used_at only after at least one actual main-DB or purgatory event removal;
archiving to Holding, git/filesystem cleanup, candidate matching, and failed
operations do not constitute use. NIP-62 uses the same rule, including its
author-wide purgatory eviction.
Deletion gate checks tombstones before kind-specific admission and rejects:
- events from vanished pubkeys,
- re-submission of deleted event IDs,
- replaceable/addressable events covered by coordinate tombstones.
NIP-62 Vanish Requests
1. Kind 62 request-to-vanish arrives
↓
2. Validate relay targeting with NIP-62 rules:
- `ALL_RELAYS` requests are actioned
- relay URL targets matching this relay are actioned (`NGIT_DOMAIN` is
interpreted as `wss://<domain>` and `ws://<domain>` when configured without
a scheme)
- non-targeting requests are accepted/stored but not actioned
↓
3. Record vanish tombstone so future events from that pubkey are gated
↓
4. For every live kind-30617 announcement authored by the vanished pubkey:
- Recursively discover the accepted-reference component
- Archive git repository to .archive/<npub>/<identifier>-<timestamp>.tar.gz
- Move announcement and newly orphaned dependents to holding DB with
holding-source=nip62
- Delete those events from the main DB
↓
5. Move any remaining main-DB events authored by the vanished pubkey to holding
with holding-source=nip62, then delete them from the main DB
↓
6. Evict the vanished author's purgatory entries
The vanish tombstone is intentionally independent from holding retention. A future re-announcement cannot bypass the vanish gate: recovery may preserve data for operators and cleanup, but publishing new events from the vanished pubkey is still rejected unless the relay gains an intentional tombstone-removal workflow.
Blacklist-Triggered Deletion
When a repository is added to NGIT_REPOSITORY_BLACKLIST, the same deletion flow applies:
1. Startup: Scan main DB for repos matching blacklist
↓
2. For each matching repository:
- Recursively discover affected accepted-reference component (same cascade logic)
- Archive git repository to .archive/<npub>/<identifier>-<timestamp>.tar.gz
- Mark metadata as "blacklist-triggered" (not NIP-09)
- Move events to holding database
- Delete from main database
↓
3. Background task (daily):
- Check holding database for expired entries
- Delete events older than retention period
- Delete corresponding archive files
Key Differences from NIP-09:
- No author validation required (operator decision)
- Triggered on startup, not by event arrival
- Metadata marks deletion as blacklist-triggered
deletion_request_disrespectordoes NOT prevent blacklist deletion (see below)
Current scope: startup reconciliation only.
Future: When dynamic blacklist updates are supported, deletion can trigger immediately on config change instead of waiting for restart.
Archival Mode (Disrespector)
When deletion_request_disrespector = true:
1. Kind 5 deletion request arrives
↓
2. Record independent lifecycle metadata and store the signed deletion request
event in the main database. Exact event-ID replay reuses its existing
lifecycle record; distinct overlapping requests remain distinct.
↓
3. Do NOT process deletion
↓
4. Repository and events remain fully accessible
↓
Result: Archival relay preserves all content
Important: Disrespector mode affects user-initiated NIP-09 deletions and NIP-62 vanish requests. It does NOT prevent blacklist-triggered deletions.
Rationale:
- NIP-09/NIP-62 deletions are user agency decisions (left-pad protection needed)
- Blacklist deletions are operator moderation decisions (spam/malware/abuse)
- Archival relays still need ability to moderate malicious content
- Different policy goals: preservation vs. safety
Implementation Note: Implemented. The LMDB backend's automatic NIP-09 and
NIP-62 processing is disabled (process_nip09(false), process_nip62(false));
ngit-grasp owns deletion handling in relay policy code. When
deletion_request_disrespector is set, new kind-5
requests are stored but not acted on, and kind-62 requests are stored but not
acted on. NIP-09 and NIP-62 are omitted from the NIP-11 supported_nips list
in this mode.
Recovery Mechanism
The holding database enables accidental deletion recovery via two distinct triggers for the same owner+identifier scope.
Trigger A: Re-announcement recovery
Scenario: repository was deleted via announcement deletion, then announced again
1. Owner publishes new announcement with same identifier
↓
2. Before parking that announcement in purgatory, system checks holding/archive
eligibility for same owner+identifier
↓
3. Check: Is entry within retention period?
↓
4. If YES:
- Extract git data from archive tar.gz
- Restore to <git_data_path>/<npub>/<identifier>.git
- Move events from holding DB → main DB
- Re-run acceptance policy (should now pass)
- Delete archive records
- Return: "Restored X events"
↓
5. If NO (expired):
- Process as new repository
- Return: "New repository created"
Trigger B: Promotion recovery (deleted active state)
When deletion removes the currently served state and announcement serving is parked in purgatory:
- Wait for a new valid authorized state event that can be promoted.
- Promotion out of purgatory runs recovery hook for same owner+identifier.
- Restore archived events/git from holding/archive.
- Resume normal serving with git/state alignment.
Rollback history source-of-truth
Rollback for deleted active replaceable/addressable repository events uses
ngit-grasp's dedicated replaceable-history store
(src/nostr/lifecycle/history.rs), not backend internal replaceable compaction
behavior.
The relay archives superseded 30617/30618 versions on write and uses that
history at deletion time to pick rollback candidates for active e-target
deletions (announcement + state).
This architecture is covered by two integration-test layers:
tests/replaceable_history.rsverifies the durable history substrate: superseded 30617/30618 payloads are captured, queryable by coordinate/cutoff, survive LMDB restart, and do not change normal serving semantics.tests/nip09_state_cascade.rsverifies deletion behavior that consumes that substrate: active 30618edeletion restores the previous state event and realigns refs, active 30617edeletion restores the previous announcement when history exists, coordinate deletion cutoff semantics do not incorrectly resurrect history, and no-active-state behavior follows the purgatory invariant below.
Active replaceable rollback and git ref restoration
When a kind-5 deletion targets the active repository state (30618) by e tag,
ngit-grasp must either restore a valid active state or stop serving the affected
repository announcement scope:
- Before destructive work, deletion policy builds rollback plans only for
e-targeted 30617/30618 events that are still the active version for their coordinate. - The deletion request is tombstoned, then the targeted active state is moved to holding and removed from the main DB.
- For a deleted active 30618, rollback looks up the latest superseded history
record for the same
30618:<pubkey>:<identifier>coordinate at or before the deleted state'screated_atcutoff. - The candidate payload is loaded from replaceable history, parsed as repository state, and accepted only if its author is still authorized for the repository announcements that remain outside purgatory.
- The candidate state event is saved back to the main DB, making it the served active nostr state again.
- The identifier is then realigned: ngit-grasp reloads repository data, selects the newest authorized active state per announcement owner, finds a repository copy containing the referenced objects, and runs the normal state-processing path to update/delete branch and tag refs in the served bare repositories.
When a kind-5 deletion targets the active repository announcement (30617) by
e tag, ngit-grasp similarly looks for the latest superseded announcement in
replaceable history and restores it when present. This e-tag rollback path is
not the same as announcement-coordinate cascade deletion:
- If a history candidate exists, the previous announcement is restored to the main DB.
- If no history candidate exists, no cascade graph is computed from the deleted announcement. The targeted announcement is moved to holding and removed from the main DB, and the live bare repository is removed when that owner+identifier announcement scope is no longer served.
- Dependent event graph cascade for announcement deletion currently belongs to
announcement-coordinate deletion (
atag), targeted NIP-62 vanish over announcements, operator-driven blacklist/whitelist deletion paths, and the no-active-state transition caused by deleting the only valid active 30618 state.
If another authorized maintainer still has a valid active state for the identifier, the surviving state remains the active source for git ref alignment; the deleted maintainer's state rollback must not disturb that maintainer's served announcement/state.
If no valid active state remains for the affected announcement scope, the relay must not keep serving an announcement with empty/stale git refs. Instead it must:
- remove the served announcement scope from the main DB;
- archive/remove the live bare repository using the same holding/archive safety semantics as announcement deletion;
- park the announcement scope in purgatory, so it is not served while git/state alignment is unresolved;
- wait for a new valid authorized state event and matching git data; and
- promote/recover the scope only through the normal purgatory promotion path, restoring any holding/archive data needed for the same owner+identifier.
Blacklist Recovery
When a repository is removed from the blacklist, startup behavior depends on
NGIT_BLACKLIST_AUTO_RESTORE:
Disabled (default):
- No startup restore sweep
- Repository remains in holding/archive unless recovered by another trigger
Enabled:
- Startup scans holding metadata with
holding-source=blacklist - Deduplicates owner+identifier scopes within retention
- Restores scopes that no longer match current blacklist
- Skips scopes still blacklisted
This keeps recovery opt-in and operator-controlled while removing manual restore steps for common unblacklist workflows.
Manual Ejection from Holding Area
Operators need ability to force-delete items from holding area before retention period expires:
Use Cases:
- Large repositories consuming excessive storage
- Confirmed malware/abuse that shouldn't be recoverable
- Legal/compliance requirements for immediate permanent deletion
Mechanism (implemented):
- Admin CLI command:
ngit-grasp holding-eject --owner <npub|hex> --identifier <id> - Immediately delete from holding DB and archive filesystem
- Emits command output with deletion counts
- Metric:
ngit_manual_ejections_total
Current CLI limitations:
- Manual ejection is permanent (no undo)
- It intentionally has no confirmation prompt, reason field, or operator field in the current CLI; richer operator UX is planned separately.
Operator Curation Integration
Overview
Blacklist- and whitelist-triggered removals use the same infrastructure as NIP-09 deletion requests and NIP-62 vanish lifecycle removals:
- Same holding database for 90-day retention
- Same git archive mechanism
- Same cascade deletion logic
- Same recovery capabilities (if unblacklisted)
Key Differences from NIP-09
| Aspect | NIP-09 Deletion | Blacklist Deletion |
|---|---|---|
| Trigger | Kind 5 event arrives | Startup scan of main DB |
| Author validation | Required (pubkey match) | Not applicable (operator decision) |
| Disrespector mode | Prevents deletion | Does NOT prevent deletion |
| Purpose | User agency | Moderation/safety |
| Recovery | Automatic (re-publish) | Startup auto-restore (optional) |
| Metadata | Links to Kind 5 event | Marks "blacklist-triggered" |
Why Disrespector Doesn't Prevent Blacklist Deletion
Design Decision: The deletion_request_disrespector configuration ONLY
affects NIP-09/NIP-62 user-initiated deletions. It does NOT prevent
blacklist-triggered deletions.
Rationale:
-
Different Policy Goals:
- NIP-09/NIP-62 = User agency (prevent left-pad)
- Blacklist = Operator safety (prevent spam/malware/abuse)
-
Archival Relays Need Moderation:
- Archive mode preserves valuable deleted content
- But still must handle malicious content
- Spam, malware, abuse require operator intervention
-
Separate Concerns:
- Disrespector = "Don't honor user deletion requests"
- Blacklist = "Don't accept these specific repos regardless of source"
Detection and Timing
Current Behavior (Startup Scan):
1. Relay starts up
2. Load blacklist configuration
3. Scan main database for matching repos
4. For each match: archive → holding DB → delete from main
5. Continue normal operation
In the same startup parity pass, repository whitelist mismatches are also
reconciled (for announcements that list this relay service but no longer match
repository_whitelist).
When NGIT_BLACKLIST_AUTO_RESTORE=true, startup also runs a blacklist restore
pass between blacklist parity delete and whitelist parity delete.
Startup also runs a whitelist restore pass after whitelist parity delete. This
restores holding scopes tagged holding-source=whitelist when they now match
repository_whitelist (while still respecting blacklist precedence).
Future Enhancement (Dynamic Updates):
- Watch for configuration file changes
- Trigger deletion/restore immediately on blacklist addition/removal
- Requires careful design to avoid race conditions
Cascade Deletion Strategy
When a repository announcement is deleted through a cascade-capable path
(NIP-09 a-coordinate deletion of a kind-30617 announcement, targeted NIP-62
vanish over the author's announcements, blacklist deletion, or whitelist
reconciliation), ngit-grasp cascade-deletes the main-DB events that lose their
accepted-reference path after that announcement is removed.
NIP-09 e-tag deletion has two important replaceable cases:
- Deleting the only valid active kind-30618 repository state can make the served announcement scope invalid. In that no-active-state case, ngit-grasp runs the announcement cascade, archives/removes live git data, and parks the scope in purgatory until a new valid state and git data arrive.
- Deleting a kind-30617 announcement by event id is narrower: it deletes the targeted announcement event and may restore a previous announcement from replaceable history, but it does not compute the generic announcement cascade when no history candidate exists.
Basic Rules
Cascade deletion is graph analysis over the same reference shapes used for event admission. It is not limited to a fixed list of repository event kinds.
- Start at the deleted announcement. The seed is the deleted kind-30617 announcement event id plus its address coordinate.
- Recursively load the affected main-DB graph. From each discovered event, load events it references and events that reference it.
- Analyze the loaded graph after removing the announcement. Events that still have a surviving accepted-repository path are retained. Events that only depended on the deleted announcement are moved to holding and removed from the main database.
- Do not promote unrelated graph regions into the cascade. Another accepted repository announcement can anchor events already found in the affected graph, but a reference to that announcement is a boundary, not permission to retain or delete that repository's entire descendant graph.
The reference forms are:
`a` / `A` — address references
`e` / `E` — event-id references
`q` — event-id or address references
The graph is built from the main database only. Holding, history, archive, and internal metadata stores are not graph inputs.
Retention Rule
After graph loading, the deleted announcement is excluded and retention is computed as a fixed point:
- Keep surviving independent anchors.
- Keep events that reference kept events or kept addresses.
- Keep events that are referenced by kept events.
- Repeat until no additional events are kept.
- Move every remaining non-special node in the component to holding and delete it from the main database.
Surviving anchors include remaining repository announcements, kind-10317 GRASP
lists, and independently valid GRASP-06 /prs/ endpoint events. The deleted
announcement itself is never a surviving anchor.
This means a deleted repository's issue, comment, or arbitrary related event can survive when it is still connected to another accepted repository/reference path. The same event is deleted when the removed announcement was its only acceptance path.
Boundaries and Special Cases
Repository state events (30618) are not handled by the generic graph. They are
keyed by repository identifier, so ngit-grasp preserves the existing identifier
semantics:
- state survives while any repository announcement for the identifier remains;
- state is moved to holding when the last announcement for that identifier is removed.
Deletion requests, vanish requests, holding metadata, and history metadata do not act as recursive graph frontiers. They are lifecycle/control records, not normal repository content anchors.
PR and PR-update events have an additional invariant: served PR data must not
outlive the git data needed to broadcast it. Generic graph reachability may keep
them only when they still reference a kept repository announcement, or when they
are independently valid through the GRASP-06 /prs/ flow. Otherwise they are
removed from the retained set and retention is recalculated.
GRASP-06 PRs are treated as their own anchored flow. They can survive repository
announcement deletion when the /prs/ endpoint relationship still makes the PR
data independently valid.
If recursive graph expansion fails or exceeds internal safety caps, ngit-grasp does not cascade-delete from a known-partial graph. It falls back to deleting the requested announcement coordinate and then applies the normal identifier-level state cleanup.
Multi-Maintainer Scenarios
Challenge
Multiple maintainers can have announcements for the same identifier:
npub1alice.../my-reponpub1bob.../my-repo
Git data is synced between their repositories. When ONE maintainer deletes, what happens?
Solution: Graph Retention
When npub1alice deletes her announcement:
1. Archive HER git directory:
.archive/npub1alice.../my-repo-<timestamp>.tar.gz
2. Recursively load the affected main-DB reference graph
3. Analyze the graph after removing alice's announcement:
- WITHOUT alice's announcement
- WITH bob's announcement still present
4. Retain events still anchored by bob's announcement:
Event A kept because:
- References bob's announcement ✓
Event B kept because:
- References Event A ✓
Event C orphaned because:
- Only referenced alice's announcement ✗
5. Delete orphaned events, keep retained events
6. Handle circular dependencies naturally:
- Event X kept because references Event Y
- Event Y kept because references Event X
- Neither has external anchor → both deleted
Graph Algorithm Details
Recursive expansion:
- Seed from the deleted announcement event id and address.
- Follow outgoing accepted-reference tags (
a,A,e,E, andq). - Query incoming references using the same tag forms.
- Treat other repository announcements as boundaries for unrelated graph expansion.
- Deduplicate by event id/address and continue until no new main-DB nodes are discovered.
Retention fixed point:
- Start from surviving independent anchors.
- Propagate retention in both directions across accepted-reference edges.
- Apply PR/PR-update git-data vetoes.
- Delete the loaded nodes that remain unretained.
Safety caps:
- Internal node, edge, and orphan-delete caps bound worst-case graph work.
- Cap/query failures fall back to coordinate-only announcement deletion plus state cleanup rather than broad deletion from a partial graph.
- These limits are code-level safeguards, not user/operator configuration.
Complexity:
- Deletion events are rare (not performance critical)
- The graph is computed on demand from indexed main-DB reference queries
- No full relay dump, pre-computation, or cache is required at current scale
Configuration
deletion_request_disrespector
Type: bool
Default: false (respects deletion requests)
CLI: --deletion-request-disrespector
Env: NGIT_DELETION_REQUEST_DISRESPECTOR
Description:
When true, relay ignores NIP-09 deletion requests and NIP-62 vanish
requests and acts as an archival server. Critical for preventing left-pad
scenarios by ensuring at least some relays preserve deleted content.
IMPORTANT: This setting does NOT prevent blacklist-triggered deletions. Blacklist is for operator moderation (spam/malware/abuse), which archival relays still need.
Use Cases:
- Community archival relays
- Research/historical preservation
- Backup/mirror relays
- GRASP-05 archive mode deployments
holding_retention_secs
Type: u64
Default: 7776000 (90 days in seconds)
CLI: --holding-retention-secs
Env: NGIT_HOLDING_RETENTION_SECS
Description: How long to retain archived events and git data before permanent deletion. Provides recovery window for accidental deletions.
Recommended Values:
- Development/Testing:
5seconds (fast test cycles) - Staging:
300seconds (5 minutes) - Production:
7776000seconds (90 days, default) - Archival Relay:
31536000seconds (1 year) or higher
Notes:
- Configurable in seconds for testing flexibility
- Background cleanup task runs on
NGIT_HOLDING_CLEANUP_INTERVAL_SECS(default daily) - Check occurs on startup to handle offline periods
- Testing Challenge: Daily cleanup doesn't work well with 3-5 second retention for tests - alternative timing strategy needed
NIP-11 Advertisement
Deletion support is conditionally advertised in NIP-11 relay information
(implemented in src/http/nip11.rs):
- When
deletion_request_disrespector = false: include9("deletion") and62("request to vanish") in the supported NIPs array - When
deletion_request_disrespector = true: do NOT include9or62(archival mode stores but does not honor deletion or vanish requests)
This allows clients to discover whether a relay respects deletion requests.
Security Considerations
Validation
- Author Matching:
- For
etargets, deletion request author MUST match deleted event author - For
atargets, only coordinates with matching author are acted on - This prevents malicious actors from deleting other people's repositories
- Enforced before destructive deletion processing
- For
- Signature Verification: Handled by nostr-relay-builder (already implemented)
- Timestamp Check: For addressable events, delete versions up to deletion
created_at
Attack Vectors
Deletion-Request Storage Pressure:
- Mitigation: bounded probation and unserved gating periods
- Mitigation: longer retention only after demonstrated local utility
- Mitigation: permanent expiry of unused request payloads and lifecycle metadata
- Mitigation: ownership checks, normal admission validation, and operator moderation
Archive Disk Exhaustion:
- Mitigation: Background cleanup enforces retention limits
- Mitigation: Compressed tar.gz archives
- Mitigation: Configurable retention period
- Mitigation: Manual ejection mechanism for emergency storage relief
Recovery Abuse:
- Mitigation: Recovery only within retention window
- Mitigation: Must be original owner (pubkey match)
- Mitigation: Normal announcement validation applies
Blacklist Bypass:
- Mitigation: Blacklist checked on startup (retroactive deletion)
- Mitigation: Blacklist checked during announcement validation (prevents new)
- Mitigation: Blacklist deletion not affected by disrespector mode
- Note: Manual ejection available for confirmed abuse
Monitoring & Metrics
Prometheus Metrics (Currently Exposed):
ngit_blacklist_deletions_total{phase,result}ngit_blacklist_startup_restore_total{result,reason}ngit_holding_cleanup_runs_totalngit_holding_cleanup_deleted_total{type}ngit_holding_cleanup_last_run_deleted{type}ngit_deletion_request_cleanup_runs_totalngit_deletion_request_cleanup_removed_total{type}ngit_deletion_request_cleanup_outcomes_total{outcome}ngit_recovery_total{result}ngit_manual_ejections_totalngit_manual_ejection_deleted_total{type}
Related Documentation
- NIP-09 Specification:
/persistent/dcdev/clones/nips/09.md - Architecture Overview:
docs/explanation/architecture.md - Configuration Reference:
docs/reference/configuration.md - Roadmap:
README.md
Future Enhancements
Remaining enhancements (updated after subsequent deletion/archival and GRASP-05 work):
Archive-mode policy extensions
- Selective disrespect: policy-based disrespect for specific criteria (e.g. popularity, community contribution, identifier allowlist).
Blacklist lifecycle improvements
- Dynamic blacklist updates: apply blacklist additions/removals without restart.
- Dedicated restore CLI: explicit operator restore commands with clear scope selection and outcomes.
Operator controls and UX
- Holding-area management UI: optional web/admin UX for archive and holding lifecycle operations.
Repository maintenance cleanup replacement
- Replace the narrow legacy cleanup command with
ngit-grasp maintenance cleanupas a deliberate full-state integrity sweep rather than a deletion-cascade helper. - Functional intent: inspect the full relay/git data set, mark events rooted in accepted repository announcements, recursively retain valid dependent events, and independently retain GRASP-06 PR/PR-update events only when their backing git data is present and consistent.
- Sweep unmarked repository-related events, then reconcile repository git dirs, state events, PR git data, and PR/PR-update events so served nostr state and filesystem git data match.
- Keep the command dry-run by default, require explicit
--executefor mutation, and report candidate deletions, degraded states, orphan git dirs, and PR/git mismatches before destructive cleanup.
Delayed archival strategy (active repositories)
- Add optional grace/delay workflow for high-activity repositories before archival execution.
- Support cancellation windows, notification issue creation, and schedulable delayed archival operations.
- Expose policy knobs (notification delay, archive delay, activity/creator thresholds).
Monitoring and integration follow-up
- Expand deletion/archival/recovery metrics and provide dashboard + alerting guidance.
Conclusion
The deletion request system balances three competing needs:
- User Agency: Owners can delete their repositories
- Community Protection: Archival relays prevent left-pad scenarios
- Recovery Grace Period: Holding database prevents accidental permanent deletion
By making deletion behavior configurable rather than mandatory, we enable a heterogeneous relay network where some relays respect deletions (user privacy) while others preserve content (community resilience).