Files
ngit-grasp/docs/explanation/repository-lifecycle.md
DanConwayDev 6e9bf5981b fix(lifecycle): preserve NIP-01 ordering through deletion and rollback
Same-second lower-ID replacements could be ignored by served-repository
de-listing and superseded-history capture. Rollback's active-state lookup
and post-deletion alignment instead preferred higher IDs, while history
selection let capture time override the original event's replacement order.

Share the later-timestamp/lower-ID comparator across these lifecycle paths
and the existing purgatory decisions. Archive a same-second predecessor only
when the incoming event wins, and only apply de-list deletion for a winning
replacement. Select active and authorized alignment states with the same
ordering. Rank history records by original timestamp and original event ID
before capture metadata; keep timestamp cutoffs and author checks intact.

Extract the authorized-state selection used by realignment to exercise both
arrival orders with different authorized authors and an unauthorized newer
state. Add served de-list, history-capture and history-candidate regressions.
All four new regressions fail before the fixes and pass afterward. Extend the
LMDB restart scenario to delete the same-second winner and observe its
archived predecessor restored, with a bounded deadline and no new sleeps.

This changes replacement preference, not deletion request ownership,
coordinate cutoff semantics, repository membership or archive format.
Existing capture metadata remains readable. Update the changelog and
repository lifecycle documentation.

Validation: focused ordering regressions and LMDB restart/rollback test;
full ngit-grasp suite, all-target workspace Clippy with warnings denied,
formatting and diff checks pass.

Assisted-by: GPT-6
2026-09-17 15:38:24 +00:00

1238 lines
60 KiB
Markdown

# 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:
1. accept valid out-of-order requests without requiring an existing target;
2. serve a new request for an intentionally generous probation window that
covers delayed rebroadcasting and intermittently connected clients;
3. retain an unserved request for a further window in which it can still block a
late event;
4. retain and serve requests that demonstrably deleted or blocked data;
5. eventually expire both the request and its admission-gate effect; and
6. 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
1. **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.
2. **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.
3. **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:
1. promote it back to the served main database if necessary;
2. serve it and enforce its admission gate for **9 months after its last use**;
3. after 9 months, stop serving it but continue enforcing its gate for a further
**3 months**; and
4. 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:
1. **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.
2. **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_at` only when an existing main-database or purgatory
target would be removed by normal policy.
3. **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.
4. **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_at` metadata 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_at` and 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:
1. prefer an already-used request over an unused request, avoiding unnecessary
promotion of another payload;
2. prefer the earliest relay-observed `first_seen_at`; and
3. 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)` and `process_nip62(false)`
(`src/nostr/builder.rs`)
- tombstones DB uses `process_nip09(false)` and `process_nip62(false)`
(`src/nostr/lifecycle/tombstones.rs`)
- holding DB uses `process_nip09(false)` and `process_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?
1. **Main Database:** Fast queries, clean data model (deleted = gone)
2. **Tombstones Database:** Durable deletion/vanish state for admission-time gating
3. **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.
### 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:
1. Start from accepted repository announcements.
2. Recursively mark repository-related events that remain valid through accepted
references.
3. Independently retain GRASP-06 PR and PR-update events only when their backing
git data exists and is consistent.
4. Treat unmarked repository-related events as cleanup candidates.
5. 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` (default `false`)
- Restores only `holding-source=blacklist` scopes that are now unblacklisted
and still within holding retention
- Still-blacklisted scopes are skipped
## 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_disrespector` does 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:
1. Wait for a new valid authorized state event that can be promoted.
2. Promotion out of purgatory runs recovery hook for same owner+identifier.
3. Restore archived events/git from holding/archive.
4. 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). Same-second replacements use the NIP-01
lower-event-ID tie-break for history capture, served-repository de-listing,
active-version selection and post-deletion state alignment. Losing replacements
and exact replays do not trigger history capture or de-list deletion.
History candidates are ordered by their original event timestamp descending,
then original event ID ascending. Capture time and metadata ID only break ties
between records for the same original event; processing order cannot override
replacement preference. Timestamp cutoffs and author checks still apply.
This architecture is covered by two integration-test layers:
- `tests/lifecycle/replaceable_history.rs` verifies 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/lifecycle/nip09_state_cascade.rs` verifies deletion behavior that consumes that
substrate: active 30618 `e` deletion restores the previous state event and
realigns refs, active 30617 `e` deletion 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:
1. Before destructive work, deletion policy builds rollback plans only for
`e`-targeted 30617/30618 events that are still the active version for their
coordinate.
2. The deletion request is tombstoned, then the targeted active state is moved to
holding and removed from the main DB.
3. 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's `created_at` cutoff.
4. 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.
5. The candidate state event is saved back to the main DB, making it the served
active nostr state again.
6. 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 (`a` tag), 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:
1. remove the served announcement scope from the main DB;
2. archive/remove the live bare repository using the same holding/archive safety
semantics as announcement deletion;
3. park the announcement scope in purgatory, so it is not served while git/state
alignment is unresolved;
4. wait for a new valid authorized state event and matching git data; and
5. 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:**
1. Large repositories consuming excessive storage
2. Confirmed malware/abuse that shouldn't be recoverable
3. 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:**
1. **Different Policy Goals:**
- NIP-09/NIP-62 = User agency (prevent left-pad)
- Blacklist = Operator safety (prevent spam/malware/abuse)
2. **Archival Relays Need Moderation:**
- Archive mode preserves valuable deleted content
- But still must handle malicious content
- Spam, malware, abuse require operator intervention
3. **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.
1. **Start at the deleted announcement.** The seed is the deleted kind-30617
announcement event id plus its address coordinate.
2. **Recursively load the affected main-DB graph.** From each discovered event,
load events it references and events that reference it.
3. **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.
4. **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:
1. Keep surviving independent anchors.
2. Keep events that reference kept events or kept addresses.
3. Keep events that are referenced by kept events.
4. Repeat until no additional events are kept.
5. 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-repo`
- `npub1bob.../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:**
1. Seed from the deleted announcement event id and address.
2. Follow outgoing accepted-reference tags (`a`, `A`, `e`, `E`, and `q`).
3. Query incoming references using the same tag forms.
4. Treat other repository announcements as boundaries for unrelated graph
expansion.
5. Deduplicate by event id/address and continue until no new main-DB nodes are
discovered.
**Retention fixed point:**
1. Start from surviving independent anchors.
2. Propagate retention in both directions across accepted-reference edges.
3. Apply PR/PR-update git-data vetoes.
4. 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: `5` seconds (fast test cycles)
- Staging: `300` seconds (5 minutes)
- Production: `7776000` seconds (90 days, default)
- Archival Relay: `31536000` seconds (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`](../../src/http/nip11.rs)):
- **When `deletion_request_disrespector = false`:** include `9` (`"deletion"`) and `62` (`"request to vanish"`) in the supported NIPs array
- **When `deletion_request_disrespector = true`:** do NOT include `9` or `62` (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
1. **Author Matching:**
- For `e` targets, deletion request author MUST match deleted event author
- For `a` targets, only coordinates with matching author are acted on
- This prevents malicious actors from deleting other people's repositories
- Enforced before destructive deletion processing
2. **Signature Verification:** Handled by nostr-relay-builder (already implemented)
3. **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_total`
- `ngit_holding_cleanup_deleted_total{type}`
- `ngit_holding_cleanup_last_run_deleted{type}`
- `ngit_deletion_request_cleanup_runs_total`
- `ngit_deletion_request_cleanup_removed_total{type}`
- `ngit_deletion_request_cleanup_outcomes_total{outcome}`
- `ngit_recovery_total{result}`
- `ngit_manual_ejections_total`
- `ngit_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 cleanup`
as 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 `--execute` for 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:
1. **User Agency:** Owners can delete their repositories
2. **Community Protection:** Archival relays prevent left-pad scenarios
3. **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).