mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
1230 lines
59 KiB
Markdown
1230 lines
59 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).
|
|
|
|
This architecture is covered by two integration-test layers:
|
|
|
|
- `tests/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/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).
|