WIP grasp-06 plan. need to work out tests

This commit is contained in:
DanConwayDev
2026-05-05 14:04:26 +01:00
parent dd9a69e6fe
commit 3d493e3750
2 changed files with 703 additions and 0 deletions
@@ -0,0 +1,250 @@
# GRASP-06: Contributor Pull Request Submission — Design
**Status**: 🚧 **PLANNED — NOT YET IMPLEMENTED** 🚧
**Spec**: [GRASP-06](https://github.com/DanConwayDev/grasp/blob/main/06.md)
**Related**: [Purgatory Design](purgatory-design.md), [Architecture](architecture.md), [Inline Authorization](inline-authorization.md)
---
## Overview
GRASP-06 adds a contributor-submission git endpoint at `/prs/<npub>/<identifier>.git` where any author can push `refs/nostr/<event-id>` for PRs and PR Updates targeting any repository — even repositories this relay has no accepted announcement for.
It exists to solve one problem: **a contributor should always have somewhere to push a PR**, even when the repository's primary GRASP servers are down, applying SPAM or curation policy that rejects the contribution, or otherwise unable to host it. Without this, the NIP-34 alternative is the "personal fork" announcement, which pollutes repo discovery with forks nobody treats as independent projects.
GRASP-06 is opt-in per operator. A relay that enables it accepts a strictly bounded relaxation of GRASP-01's event-acceptance rules and opens one additional URL namespace.
## What GRASP-06 changes
1. **New URL namespace**: `/prs/<npub>/<identifier>.git` (git smart HTTP, unauthenticated).
2. **Event acceptance relaxation**: PR (kind 1618) and PR Update (kind 1619) events that would be rejected under GRASP-01 for not referencing an accepted repository announcement are instead accepted into purgatory, provided the event's `clone` tag names our `/prs/<signer>/<identifier>.git` endpoint.
3. **Cross-service mirroring**: when a PR or PR Update is released from purgatory via the `/prs/` endpoint, its `refs/nostr/<event-id>` is synced into any accepted repository announcements on this relay whose coordinate appears in the event's `a` tags.
Everything else — authorization on the standard endpoint, repository announcement purgatory, PR purgatory bidirectional waiting, proactive sync, NIP-11, etc. — is unchanged.
## Why this shape
### Why a separate URL namespace
Standard `/<npub>/<identifier>.git` carries the GRASP-01 authorization model: pushes are authorised against the repository announcement's maintainer set. That model fundamentally cannot serve the contributor-submission use case, since the contributor is not in the maintainer set and the announcement may not even exist on this relay. Rather than overloading the standard endpoint with two auth models, GRASP-06 introduces a distinct namespace whose rules are deliberately different.
### Why `refs/nostr/<event-id>` is the only accepted ref form
The ref name is the nostr event id. The event is signed, its `c` tag pins a commit, and the ref must match that commit for the event to be released from purgatory. This makes the ref:
- immutable once locked (the signed event's commit never changes),
- self-verifying (server can recompute the binding from the event),
- trivially garbage-collectable (ref name maps 1:1 to an event-id lifecycle).
No `refs/heads/*` or other namespaces are accepted — there is no authorised maintainer to decide what branches mean.
### Why no HTTP auth on push
The entire gate is the signed PR/PR Update event. The event's signer, d-tag and `c` tag determine whether the ref is valid. Adding NIP-98 or similar would be redundant — a forged push without a matching event is dropped in purgatory within 20 minutes, and a matching event cannot be produced without the signer's key.
### Why the event must name us in its `clone` tag
Two reasons:
1. **Intent check**: it proves the contributor explicitly chose this relay as a destination, not that we happen to match a naming pattern by accident.
2. **Preventing replay/fan-out abuse**: without the `clone` tag, every GRASP-06 relay on the network would accept every PR event that happens to match its URL shape, multiplying storage pressure across the ecosystem.
### Why mirror `/prs/ → /<maintainer>/` but not the other way
The contributor chose to publish to `/prs/`. Mirroring into any accepted repository on this relay makes the PR visible at the expected location for clients browsing that repo. Not mirroring the reverse direction preserves the maintainer's declared `clone` intent on their own pushes — we do not invent new hosting locations for their events.
Until object-pool dedup lands, the mirror doubles storage for affected refs. We accept this cost as the simplest correct implementation; dedup lands later and makes the mirror effectively free.
## Architecture
### URL routing
```
GET/POST /<npub>/<id>.git/* → existing standard endpoint (unchanged)
GET/POST /prs/<npub>/<id>.git/* → new GRASP-06 endpoint (this doc)
```
`prs` is a reserved top-level path segment. Because valid npubs start with `npub1`, there is no collision with existing routing.
### Fetch semantics
```
GET /prs/<npub>/<id>.git/info/refs?service=git-upload-pack
POST /prs/<npub>/<id>.git/git-upload-pack
GRASP-06 disabled → 404
Repo exists on disk → serve from disk (normal git-upload-pack)
Repo does not exist → synthesize empty-repo response (no disk state created)
```
Synthesizing the empty response avoids creating a directory per URL probe. It also means clients can speculatively clone any well-formed `/prs/<npub>/<id>.git` URL and get a zero-ref repo back, matching what they would get the moment before any contributor has pushed.
### Push semantics
```
POST /prs/<npub>/<id>.git/git-receive-pack
GRASP-06 disabled → 404
Pushed ref not refs/nostr/<64-hex> → reject (ERR pkt-line)
Repo does not exist → git init --bare on first valid ref seen
For each refs/nostr/<event-id> pushed:
event found in DB or purgatory:
signer ≠ URL npub → reject ref
identifier (d-tag) ≠ URL identifier → reject ref
commit ≠ event's c tag → delete ref
all match → ref locked; release event from purgatory;
mirror to matching standard repos
event not yet seen:
accept ref; create or update PR placeholder in purgatory
scoped to (submitter, identifier, event-id)
After receive-pack:
repo ended with zero refs → rm -rf the repo directory
```
The flow mirrors the existing `refs/nostr/<event-id>` path at the standard endpoint (see [`src/git/handlers.rs:handle_receive_pack`](../../src/git/handlers.rs) and the PR purgatory entries in [`src/purgatory/types.rs`](../../src/purgatory/types.rs)) — it just skips the `authorize_push` path that checks the maintainer set, and applies the spec's validation invariant instead.
### Event acceptance relaxation
The existing PR event policy in [`src/nostr/policy/pr_event.rs`](../../src/nostr/policy/pr_event.rs) calls `fetch_repository_data_excluding_purgatory` to require an accepted announcement in the database before accepting a PR event. Under GRASP-06 this check is loosened for events that satisfy:
- signer pubkey,
- a d-tag on any `a` tag,
- and a `clone` tag pointing at this relay's `/prs/<signer>/<d-tag>.git`.
Such events skip the "references accepted announcement" check and are accepted into purgatory directly. They remain in purgatory until the matching push arrives at `/prs/<signer>/<d-tag>.git` (standard 30-minute TTL applies).
Events that qualify under the existing GRASP-01 rules still flow through the normal path unchanged — the GRASP-06 branch is only taken when the existing path would have rejected.
### Cross-service mirror
When a PR or PR Update's purgatory entry is released via a `/prs/` push:
1. Save event to DB and remove from purgatory (as today).
2. For each `a` tag in the event of the form `30617:<pubkey>:<d-tag>`:
- Resolve to a local repo path `<git_data_path>/<pubkey-npub>/<d-tag>.git`.
- If that repo has an active (non-purgatory) announcement, copy objects + install `refs/nostr/<event-id>` into it (same mechanism as existing cross-owner sync in [`src/git/sync.rs`](../../src/git/sync.rs)).
The mirror copies the same ref, same commits. No separate object store. Dedup can be added transparently later via git alternates keyed on d-tag.
### Purgatory integration
No new purgatory entry types are strictly required. The existing `PrPurgatoryEntry` already supports event-first and git-first patterns keyed by event id. Under GRASP-06:
- **Event-first**: event arrives, GRASP-06 relaxation accepts it, normal purgatory `add_pr` call, waits for any push that materialises `refs/nostr/<event-id>` with matching commit — at either the standard endpoint or `/prs/`.
- **Git-first**: push arrives at `/prs/<npub>/<id>.git`, normal `add_pr_placeholder` call keyed by event-id. Validation at the `/prs/` receive-pack handler additionally records the `(submitter, identifier)` tuple for later matching (see Implementation Plan §5).
Placeholder entries created at `/prs/` should be validated against `(submitter == url_npub) && (d-tag == url_identifier)` when their event arrives. A small additional field on `PrPurgatoryEntry` captures the scoping when the placeholder was created by the `/prs/` path.
### Exclusion from other subsystems
- **Empty-repo cleanup** ([`src/cleanup_empty_repos.rs`](../../src/cleanup_empty_repos.rs)): skip `<git_data_path>/prs/*`. The `/prs/` endpoint has its own cleanup rule (zero-refs repos, expired placeholders).
- **Proactive sync** (GRASP-02): `/prs/` repos are not replicated between relays. A GRASP-06 relay is authoritative for the PRs it accepts. Clients that need a PR should fetch it from the relay the event's `clone` tag names.
- **Repo listings / NIP-11**: `/prs/` repos are not advertised as repositories. They are a submission side-channel, not first-class hosted repos.
## Flow examples
### Event-first (most common)
```
1. Contributor publishes kind 1618 PR event to network.
Event's `a` tag: 30617:<maintainer-pubkey>:my-project
Event's `c` tag: <commit-hash>
Event's `clone` tag: https://grasp-06-relay.example/prs/<contributor-npub>/my-project.git
2. Event reaches grasp-06-relay via WebSocket.
- Existing PR policy: no accepted announcement for <maintainer>:my-project → would reject.
- GRASP-06 relaxation: event has `clone` tag naming our /prs/ endpoint, matches
signer+d-tag invariant → accept to purgatory.
- Response: OK true "purgatory: won't be served until git data arrives".
3. Contributor runs `git push https://grasp-06-relay.example/prs/<contributor-npub>/my-project.git
<commit-hash>:refs/nostr/<event-id>`.
4. Server receives push:
- Creates /prs/<hex>/my-project.git if missing.
- git-receive-pack writes refs/nostr/<event-id>.
- Post-push check: finds PR event in purgatory. Signer matches URL npub.
d-tag matches URL id. Commit matches event's c tag. Ref is locked.
- Event promoted: saved to DB, removed from purgatory, subscribers notified.
- Mirror: if we have an accepted announcement 30617:<maintainer>:my-project
locally at <maintainer>/my-project.git, copy refs/nostr/<event-id> into it.
```
### Git-first
```
1. Contributor pushes first (race between git push and event propagation).
Server creates /prs/<hex>/<id>.git, accepts refs/nostr/<event-id>.
No matching event anywhere → placeholder created scoped to
(submitter=<url-npub>, identifier=<url-id>, event-id).
2. Event arrives:
- Placeholder is found by event-id.
- Validation: signer matches placeholder.submitter, d-tag matches placeholder.identifier,
c tag matches stored commit → ref locked; event promoted.
3. If event never arrives within 20 minutes: ref deleted, repo dir may be removed
if it becomes ref-less.
```
### Non-matching event
```
1. Push arrives with refs/nostr/<event-id> for commit A.
2. Event arrives with c tag = commit B (mismatch).
- Ref refs/nostr/<event-id> deleted from /prs/<npub>/<id>.git.
- Event goes through normal PR policy; if no matching git data elsewhere, purgatory.
- Placeholder discarded.
```
## Configuration
One new flag:
- `NGIT_GRASP06_ENABLE` (bool, default `false`) — opts the relay in.
No further knobs in the initial implementation. Future additions (stubbed for plan only, not built): allowlist, per-npub disk quota, per-ref size cap, PoW difficulty.
## Interactions with planned but unbuilt features
### Maintainer curation (not yet specified)
GRASP-06 anticipates but does not depend on curation. The envisaged integration when curation lands:
- Maintainer curation-remove of a PR event → deletes the mirrored `refs/nostr/<event-id>` at `<maintainer>/<id>.git` and removes the event from the event DB.
- The origin ref at `/prs/<contributor-npub>/<id>.git` **survives** — the contributor can republish the event to another relay and clients fetching from there can still pull the commits.
This is the design rationale for the mirror being one-directional and the `/prs/` ref surviving independently.
### Deletion requests (in-progress on separate branch)
Deletion of the contributor's own PR event (NIP-09) → the deletion-request branch will decide and implement the ref-lifecycle behaviour. GRASP-06 v1 does not add hooks or no-op scaffolding for this.
### Object-pool deduplication (not yet specified)
The mirror copies objects today. When object-pool dedup exists — likely as git alternates keyed on `(identifier, a-tag-coord-set)` — the mirror becomes a pointer operation with near-zero storage cost. No URL or spec change is required.
## Anticipated failure modes and mitigations
| Mode | Mitigation |
|---|---|
| Probe attacks that create empty `/prs/<any>/<any>.git` dirs | Synthesise empty-repo response for fetches without creating disk state; zero-ref cleanup after receive-pack discards empty probes |
| Oversized pushes | None in v1; future config knob (`NGIT_GRASP06_MAX_BYTES_PER_REF`) |
| Spam PR events without pushes | Standard 30-min purgatory TTL drops them |
| Mismatched event vs push commits | Ref deleted on detection (same as standard endpoint `refs/nostr/` behaviour) |
| Concurrent pushes to same `(npub, id)` | git-receive-pack's file locking handles intra-repo concurrency; different repos are independent |
| Cross-endpoint double-push of same event | Both endpoints materialise the ref independently; second push validates via DB lookup of already-promoted event (no purgatory entry needed) |
## File touchpoints (informational)
The implementation plan details exactly what changes where. At a glance:
- New: `src/grasp06/` module (endpoint parsing, receive-pack handler, fetch synthesis, mirror).
- Modified: `src/http/mod.rs` (route `/prs/*` ahead of standard routing), `src/nostr/policy/pr_event.rs` (relaxation branch), `src/purgatory/types.rs` (optional placeholder scope field), `src/purgatory/mod.rs` (scope-aware placeholder lookup), `src/cleanup_empty_repos.rs` (skip `/prs/*`), `src/sync/*` (skip `/prs/*`), `src/config.rs` (new flag), `src/http/nip11.rs` (advertise GRASP-06).
See `plans/grasp-06-implementation-plan.md` for the sequenced work breakdown.
---
*Part of the [ngit-grasp explanation docs](./)*
+453
View File
@@ -0,0 +1,453 @@
# GRASP-06 Implementation Plan
**Target spec**: [GRASP-06](https://github.com/DanConwayDev/grasp/blob/main/06.md) (draft)
**Design doc**: [`docs/explanation/grasp-06-contributor-pr-submission.md`](../docs/explanation/grasp-06-contributor-pr-submission.md)
**Baseline**: master with the PR purgatory work already merged.
**Audience**: the implementing agent. Read the spec and design doc first, then this.
This document sequences the work. No tests are specified here — test planning is a separate workstream once the implementation shape is agreed.
---
## Ground rules
1. **Do not build features the spec does not require.** In particular: no NIP-98 auth on `/prs/`, no allowlist/quota/PoW in v1, no deletion-request hooks, no object-pool dedup, no `/prs/` replication.
2. **Do not regress the standard `/<npub>/<identifier>.git` endpoint.** Its auth model (`authorize_push` / maintainer set) is unchanged.
3. **Reuse what exists.** `PrPurgatoryEntry`, `refs/nostr/<event-id>` handling, `process_newly_available_git_data`, the existing receive-pack subprocess plumbing, `process_pr_with_git_data`, and the cross-owner sync in `src/git/sync.rs` already cover most of this feature. The new code path is thin on top.
4. **Gate everything behind `NGIT_GRASP06_ENABLE`.** When disabled, `/prs/*` returns 404 and the event-acceptance relaxation does not apply. Default must be `false`.
5. **Keep the `/prs/` tree on disk separate from the standard tree.** `<git_data_path>/prs/<hex-pubkey>/<identifier>.git`. This makes exclusion from other subsystems a single path check.
6. **Follow the existing style.** Config env var naming (`NGIT_GRASP06_*`), `AGENTS.md` config-sync rules (code + `.env.example` + docs + NixOS module — four places), inline authorization (no pre-receive hooks), NIP-11 advertisement.
---
## Phase order
Each phase is independently mergeable. Later phases assume earlier phases are in place. Run `cargo build` and `cargo clippy --all-targets` after every phase; do not commit phases that do not at least build.
1. Config + feature flag
2. Disk layout + path helpers
3. HTTP routing and empty-repo fetch synthesis
4. `/prs/` receive-pack handler
5. Purgatory placeholder scoping
6. Event-acceptance relaxation in PR policy
7. Cross-service mirror on purgatory release
8. Exclusion from cleanup / sync / listings
9. NIP-11 advertisement
10. Documentation hygiene and release notes
---
## Phase 1 — Config + feature flag
**Goal**: one bool flag, default `false`, wired into `Config`, `.env.example`, docs, NixOS module.
**Change**:
- `src/config.rs` — add:
```rust
/// Enable GRASP-06 contributor PR submission endpoint at /prs/<npub>/<identifier>.git
#[arg(long, env = "NGIT_GRASP06_ENABLE", default_value_t = false)]
pub grasp06_enable: bool,
```
Place it alongside the other `NGIT_*` flags, after the archive block is a natural home.
- `.env.example` — add the usual four-line comment block documenting CLI, default, and env var.
- `docs/reference/configuration.md` — add an entry under the same ordering convention as other flags. Describe: what it enables, the default (off), and a one-line summary of the security model ("unauthenticated — relies on signed PR/PR Update events for validity").
- `nix/module.nix` — add `grasp06Enable` to `instanceOptions` as `mkOption { type = types.bool; default = false; description = "..."; }` and wire it into the environment block as `NGIT_GRASP06 = toString cfg.grasp06Enable;`. Match the existing naming pattern for other env vars in that file.
**Deliverable**: a no-op flag that compiles, is plumbed through `Config`, and defaults to off.
**Do not**: add any downstream logic yet. Just the flag.
---
## Phase 2 — Disk layout + path helpers
**Goal**: a small module exposing the `/prs/` filesystem conventions, independent of HTTP.
**Create**: `src/grasp06/mod.rs` with submodules declared for later phases:
```rust
//! GRASP-06 — Contributor Pull Request Submission.
//!
//! Spec: https://github.com/DanConwayDev/grasp/blob/main/06.md
//! Design: docs/explanation/grasp-06-contributor-pr-submission.md
pub mod endpoint; // URL parsing
pub mod paths; // on-disk path helpers (this phase)
pub mod fetch; // empty-repo synthesis (later phase)
pub mod receive; // receive-pack handler (later phase)
pub mod mirror; // cross-service mirror (later phase)
```
**In `paths.rs`**:
- Constant: `pub const PRS_URL_PREFIX: &str = "prs";` and `pub const PRS_DISK_PREFIX: &str = "prs";` (spec-fixed).
- `pub fn prs_repo_path(git_data_path: &Path, submitter_hex: &str, identifier: &str) -> PathBuf` returning `<git_data_path>/prs/<submitter_hex>/<identifier>.git`.
- `pub fn is_prs_repo_path(path: &Path, git_data_path: &Path) -> bool` — used by cleanup/sync exclusion checks.
- `pub fn prs_base_path(git_data_path: &Path) -> PathBuf` returning `<git_data_path>/prs`.
Use `hex` (not `npub`) on disk to match the existing scheme where pubkeys are stored as hex under the owner directory. (Look at `src/git/authorization.rs` and `src/git/sync.rs` for the convention to match.) Identifiers are stored verbatim on disk; percent-decoding happens in the URL parser.
**In `endpoint.rs`**:
- `pub struct PrsUrl { pub submitter: PublicKey, pub identifier: String, pub subpath: String }`.
- `pub fn parse_prs_url(path: &str) -> Option<PrsUrl>` — parses `/prs/<npub>/<id>.git/<subpath>` with percent-decoded identifier and bech32-decoded pubkey. Rejects hex pubkeys (spec is npub-only). Rejects `prs` as a literal first component if `<npub>` slot doesn't start with `npub1`.
**Do not**: touch `src/http/mod.rs` yet.
**Deliverable**: compiles. Unit-testable pure functions ready for later phases.
---
## Phase 3 — HTTP routing and empty-repo fetch synthesis
**Goal**: `GET/POST /prs/<npub>/<id>.git/*` resolve through the new module. Fetches against non-existent repos return an empty-repo advertisement. Everything else 404s until later phases land.
### Routing (`src/http/mod.rs`)
Add a branch in `HttpService::call` **before** the existing `parse_git_url` branch:
```rust
if self.config.grasp06_enable {
if let Some(prs) = crate::grasp06::endpoint::parse_prs_url(&path) {
// dispatch to grasp06::fetch or grasp06::receive
}
}
```
If `grasp06_enable` is false, the path falls through to normal 404 handling.
Keep routing responsibilities narrow: the match above should dispatch on `(method, prs.subpath)` exactly as the existing git branch does, but route to the new module's handlers.
### Empty-repo fetch synthesis (`src/grasp06/fetch.rs`)
Implement:
```rust
pub async fn handle_prs_info_refs(prs: PrsUrl, service: GitService, git_protocol: Option<&str>, ...) -> ...
pub async fn handle_prs_upload_pack(prs: PrsUrl, body: Bytes, git_protocol: Option<&str>, ...) -> ...
```
Behaviour:
- If the real repo at `prs_repo_path(...)` exists on disk → delegate to the existing handlers in `src/git/handlers.rs` (`handle_info_refs`, `handle_upload_pack`). Consider refactoring those to accept a `PathBuf` directly if they don't already; the current signatures should be fine.
- Otherwise → synthesize an empty-repo response.
To synthesize:
1. `git init --bare` into a temp dir (tempfile crate, already a dep).
2. Run `git-upload-pack` or `git-http-backend`-equivalent against the temp dir.
3. Return the bytes. Clean up temp dir.
An acceptable v1 alternative if a temp-dir spawn per probe is too expensive: keep a process-wide single empty bare repo (lazily created at first `/prs/` fetch, under `<git_data_path>/prs/.empty-template.git`) and serve upload-pack from it for any missing `/prs/<npub>/<id>.git`. Because git-upload-pack is read-only, a shared template is safe for concurrent reads. **Do not reuse this template for any push.**
Either implementation is acceptable. Pick the shared-template approach if the temp-dir spawn cost is visible in manual testing; otherwise the temp-dir approach is simpler.
### Receive-pack stub
For Phase 3 only, wire `POST /prs/.../git-receive-pack` to return an `ERR` pkt-line saying "not yet implemented". Phase 4 replaces this.
**Deliverable**: `git clone https://host/prs/<any-valid-npub>/<any-id>.git` succeeds and produces an empty repo when GRASP-06 is enabled. Returns 404 when disabled. Pushes are explicitly rejected with a clear message.
---
## Phase 4 — `/prs/` receive-pack handler
**Goal**: accept `refs/nostr/<event-id>` pushes. Create the real bare repo on demand. Apply the spec validation against DB+purgatory.
### Implement `src/grasp06/receive.rs`
Structure mirrors `src/git/handlers.rs:handle_receive_pack` but with important differences:
1. **No `authorize_push` call.** Skip the maintainer-set check entirely — that's the whole point of this endpoint.
2. **Pre-scan the push body** using `parse_pushed_refs` (already exists in `src/git/authorization.rs` — lift it into `crate::git::pub use` if it isn't already public). Reject the entire push with an ERR pkt-line if any ref is not `refs/nostr/<64-hex-event-id>`.
3. **Create the real repo** at `prs_repo_path(...)` with `git init --bare --initial-branch=main` **before** running `git-receive-pack`. Use `std::fs::create_dir_all` for the parent — it's idempotent. Use a per-(submitter_hex, identifier) mutex around `git init` to avoid racing two simultaneous first pushes. A `DashMap<PathBuf, tokio::sync::Mutex<()>>` on the HTTP service struct is sufficient. Release the mutex once init is complete (not for the whole push — git's own locking handles intra-push concurrency).
4. **Run `git-receive-pack`** using the existing `GitSubprocess` helpers.
5. **Post-push processing**: parse `pushed_refs` (new_oids for each `refs/nostr/<event-id>`), then for each:
- Validate against the DB first: if a matching PR/PR-Update event exists in the DB, check signer/d-tag/commit. On commit mismatch, `delete_ref(&repo_path, &format!("refs/nostr/{}", event_id))`.
- Validate against purgatory: if an entry exists for this event-id, check the same invariants. On commit mismatch, delete the ref and remove the purgatory entry.
- If no event is known, call `purgatory.add_pr_placeholder(event_id, commit)` — but see Phase 5 for the scoping change this requires.
6. **Repo cleanup**: after processing, count refs in the repo with `list_refs`. If zero, `std::fs::remove_dir_all` the repo directory. This discards probe-pushes that wrote nothing valid.
7. **Trigger release**: call `process_newly_available_git_data` on the new repo path, same as the standard endpoint does. This will promote any matching purgatory events and notify subscribers. Pass the `/prs/` repo path through; Phase 7 will branch on whether the source is a `/prs/` repo to trigger mirroring.
### Wire into Phase 3 routing
Replace the Phase 3 stub for `POST .../git-receive-pack` with a call into `receive::handle_prs_receive_pack`.
**Deliverable**: a contributor can push `refs/nostr/<event-id>` to `/prs/<npub>/<id>.git`. Ref is written. If the event is already in the DB, validation runs and mismatches are rejected. If not, a placeholder is created.
---
## Phase 5 — Purgatory placeholder scoping
**Goal**: when a PR event arrives for a placeholder created via `/prs/`, validate signer and d-tag against the placeholder's scope.
### Extend `PrPurgatoryEntry`
In `src/purgatory/types.rs`, add an optional field:
```rust
/// If set, this placeholder was created by a /prs/<submitter>/<identifier>.git push
/// under GRASP-06. When the corresponding event arrives, its signer must equal
/// `submitter` and it must carry an `a` tag with d-tag equal to `identifier`.
#[serde(default)]
pub prs_scope: Option<PrsPlaceholderScope>,
```
Define `PrsPlaceholderScope { pub submitter: PublicKey, pub identifier: String }`. `#[serde(default)]` keeps persistence backward-compatible with on-disk state files written before this field existed.
Also update the `SerializablePrPurgatoryEntry` in `src/purgatory/mod.rs` to carry the new field with `#[serde(default)]`.
### Extend the purgatory API
Add:
```rust
impl Purgatory {
pub fn add_prs_pr_placeholder(
&self,
event_id: String,
commit: String,
submitter: PublicKey,
identifier: String,
) { ... }
}
```
Internally this stores a `PrPurgatoryEntry { event: None, commit, prs_scope: Some(...), .. }` keyed by `event_id`.
Keep the existing `add_pr_placeholder` (no-scope) for the standard endpoint's flow.
### Enforce scope on event arrival
In `src/nostr/policy/pr_event.rs::git_data_check`, after finding a matching placeholder via `find_pr_placeholder`, also read the full entry via `find_pr` and check `prs_scope`:
- If `prs_scope` is `None` (standard flow) → existing behaviour (commit match is sufficient).
- If `prs_scope` is `Some(scope)`:
- Verify `event.pubkey == scope.submitter`. On mismatch, delete the `/prs/<submitter>/<identifier>.git/refs/nostr/<event_id>` ref and discard the placeholder. The arriving event does NOT satisfy this placeholder; it may still go through the normal GRASP-01/06 flow.
- Verify some `a` tag in the event has d-tag `== scope.identifier`. On mismatch, same treatment as above.
- If both pass, proceed as normal.
### Wire from Phase 4
The Phase 4 receive handler now calls `add_prs_pr_placeholder` instead of `add_pr_placeholder`.
**Deliverable**: placeholders created by `/prs/` pushes correctly validate the later-arriving event against the URL's submitter and identifier.
---
## Phase 6 — Event-acceptance relaxation in PR policy
**Goal**: PR and PR Update events that would fail GRASP-01 acceptance (no matching accepted announcement) are instead accepted to purgatory when they meet the GRASP-06 invariant.
### Locate the rejection
Currently in `src/nostr/policy/pr_event.rs` (and/or `src/nostr/policy/related.rs` and the write policy chain) a PR event without a matching accepted announcement is ultimately rejected. Trace the actual rejection point — the PR policy's `git_data_check` returns early when `find_relevant_repo_paths` returns empty because there are no announcements; the caller in the write-policy chain then rejects.
### Add the relaxation branch
When GRASP-06 is enabled and the existing path would reject a kind 1618 or 1619 event because no matching accepted announcement exists, check the GRASP-06 invariant:
```
let qualifies_for_grasp06 =
config.grasp06_enable
&& matches!(event.kind, Kind::Custom(1618) | Kind::Custom(1619)) // exact kind ids TBD — verify against nostr-sdk
&& event.tags has at least one `a` tag of form "30617:<hex>:<d>"
&& event.tags has at least one `clone` tag whose value resolves to this relay's /prs/<signer-npub>/<d>.git URL
where <signer-npub> == bech32(event.pubkey) and <d> matches one of the a-tag d-tags
```
If all conditions hold:
- Add the event to purgatory via `add_pr(event, event_id, commit, from_sync)` (existing API), marking it as belonging to the GRASP-06 flow. To distinguish the GRASP-06 flow on the event-first side, extend `PrPurgatoryEntry` with a `from_grasp06: bool` (`#[serde(default)]`) or mirror the placeholder scoping by allowing an optional `prs_scope: Option<PrsPlaceholderScope>` on event-first entries too (preferred — same field, same type). The scope is derivable from the event at insertion time.
- Return `WritePolicyResult::Ok(None, Some(msg))` with the standard "purgatory: won't be served until git data arrives" message.
If any condition fails, fall through to the existing rejection.
### Clone-tag matching
Resolving "this relay's `/prs/<signer>/<d>.git` URL" means the clone tag's host must equal `config.domain` (or one of its aliases if the project tracks aliases — check existing code that handles the standard endpoint's clone tag validation) and the path must equal `/prs/<signer-npub>/<percent-encoded-d>.git`. Percent-encoding comparison should be normalised both sides.
There is already announcement-side `clone` tag validation code in the repository that matches host + path — reuse that comparator where possible.
### Do not relax for anything other than 1618/1619
Kind checks must be exact. This relaxation must not extend to patches (1617), issues, announcements, state events, or deletions.
**Deliverable**: a PR event naming our `/prs/` endpoint in its `clone` tag is accepted to purgatory even without a matching accepted announcement. A PR event not naming our endpoint is still rejected if it would have been rejected before.
---
## Phase 7 — Cross-service mirror on purgatory release
**Goal**: when a PR/PR-Update is released from purgatory via a `/prs/` push, copy `refs/nostr/<event-id>` into any matching accepted-announcement repos on this relay.
### Trigger point
The natural hook is inside `process_newly_available_git_data` (`src/git/sync.rs`) — it already runs after receive-pack on any endpoint, identifies released events, and syncs refs across owner repos. Read its existing implementation carefully before changing it.
Detect the `/prs/` source by checking whether the passed-in repo path is under `prs_base_path(git_data_path)` using `is_prs_repo_path`. If so:
- For each released PR event, iterate `event.tags` looking for `a` tags of the form `30617:<hex-pubkey>:<d-tag>`.
- For each `a` tag, compute `<git_data_path>/<maintainer-npub>/<d-tag>.git`. If the repo exists AND an announcement for that coord is in the DB (not purgatory), mirror `refs/nostr/<event-id>` and its reachable objects into that repo using the same mechanism already used for cross-owner sync. The simplest path is to reuse the existing `sync_oids` / `sync_refs` helpers invoked today when the same `refs/nostr/<id>` is authoritative for multiple repos.
### One-directional
Do not do the reverse: pushes to `/<maintainer>/<id>.git` must not be mirrored into `/prs/*`. This is already the default since `/prs/` won't be in the set of "authorized owner repos" that cross-owner sync considers; just make sure the mirror logic in `process_newly_available_git_data` is only entered when the source is under `/prs/`.
### Absent announcements
If no accepted announcement exists for any of the event's `a` tags, no mirror happens. That's fine — the ref remains at `/prs/` and clients can still fetch from there via the event's `clone` tag.
### Back-fill on later announcement (deferred)
If an announcement is accepted *after* a `/prs/` push has already occurred, the current design does not back-fill. Leave this as a follow-up; note it as a TODO in `mirror.rs` with a pointer to announcement-promotion code. Not required for v1.
**Deliverable**: when the mirror conditions are met, a PR released via `/prs/` becomes fetchable at `<maintainer>/<id>.git` automatically.
---
## Phase 8 — Exclusion from cleanup / sync / listings
**Goal**: existing subsystems do not treat `/prs/*` as standard repos.
### `src/cleanup_empty_repos.rs`
Skip any path under `prs_base_path`. Use `is_prs_repo_path` for the check. The `/prs/` endpoint has its own cleanup rules — implemented inline in the receive handler (zero-refs repos) and in a new periodic sweep (see below).
### `src/sync/*` (GRASP-02 proactive sync)
Repo discovery iterates `<git_data_path>/<owner>/<id>.git`. Skip the `prs` directory explicitly when walking. Also ensure `SelfSubscriber` / announcement promotion logic never constructs sync subscriptions for `/prs/` repos — they have no announcement.
### Repo listings / webpages
`src/http/mod.rs::parse_repo_url` (used for landing pages / 404 pages) should not match `/prs/...` paths. The new routing branch in Phase 3 intercepts `/prs/` earlier, so this should already be the case — but verify by inspection and add an early-return in `parse_repo_url` if `path` starts with `/prs/` to be defensive.
### Periodic `/prs/` cleanup
Add a periodic task (e.g. every 10 minutes) that walks `<git_data_path>/prs/` and:
- Removes any `.git` repo with zero refs AND no active placeholder in purgatory for that repo path.
- Logs the count of directories removed.
A conservative implementation: only remove if the dir's mtime is older than the purgatory TTL (30 minutes) to avoid racing with in-flight pushes. Wire this into the existing background-task scheduler (there's already a cleanup task registry; match its style).
**Deliverable**: enabling GRASP-06 does not pollute sync state, doesn't make `cleanup_empty_repos` thrash, doesn't show `/prs/` repos in landing pages, and orphan `/prs/` dirs get cleaned up.
---
## Phase 9 — NIP-11 advertisement
**Goal**: relay advertises GRASP-06 when enabled.
In `src/http/nip11.rs`, update `RelayInformationDocument::from_config`:
```rust
if config.grasp06_enable {
supported_grasps.push("GRASP-06".to_string());
}
```
Place insertion order consistent with the existing list (see how GRASP-05 and GRASP-02 are ordered).
Also add an optional `contributor_pr_policy` field to `RelayInformationDocument`, serialised only when present. In v1, set it to `None` (no knobs yet). Future phases with allowlist/quotas fill this.
**Deliverable**: clients discover `/prs/` support by parsing NIP-11.
---
## Phase 10 — Documentation hygiene and release notes
**Goal**: keep `AGENTS.md`'s configuration-sync contract satisfied, update the architecture doc, add a how-to.
1. **Verify the 4-place config sync** (already done in Phase 1, re-verify here): `src/config.rs`, `.env.example`, `docs/reference/configuration.md`, `nix/module.nix`. Env var names match. Defaults match. Descriptions match.
2. **Update `docs/explanation/architecture.md`** to mention the `/prs/` endpoint as an optional subsystem, with a pointer to the GRASP-06 design doc.
3. **Add `docs/how-to/enable-grasp-06.md`** (short, task-oriented): steps for an operator to enable the feature, what it costs in storage, what abuse controls are planned, link to the spec.
4. **Update `CHANGELOG.md`** under a new unreleased section with a user-facing summary.
5. **Flip the design doc's status** from "PLANNED — NOT YET IMPLEMENTED" to "Implemented" once all earlier phases are complete.
**Deliverable**: an operator reading the docs can enable the feature confidently.
---
## Reference: module layout after completion
```
src/
grasp06/
mod.rs # module root, public re-exports
endpoint.rs # PrsUrl struct, parse_prs_url
paths.rs # prs_repo_path, is_prs_repo_path, constants
fetch.rs # handle_prs_info_refs, handle_prs_upload_pack, empty-repo synthesis
receive.rs # handle_prs_receive_pack: init-on-push, validation, placeholder scoping
mirror.rs # mirror_to_matching_announcements (called from git::sync)
cleanup.rs # periodic sweep of zero-ref /prs/ repos
http/
mod.rs # route /prs/* ahead of standard routing
nip11.rs # advertise GRASP-06
nostr/policy/
pr_event.rs # relaxation branch; scope enforcement on event arrival
purgatory/
types.rs # PrsPlaceholderScope; PrPurgatoryEntry.prs_scope field
mod.rs # add_prs_pr_placeholder; persistence with serde(default)
git/
sync.rs # process_newly_available_git_data: mirror branch for /prs/ sources
cleanup_empty_repos.rs # skip <git_data_path>/prs/*
config.rs # grasp06_enable field
.env.example # NGIT_GRASP06_ENABLE
docs/
explanation/
grasp-06-contributor-pr-submission.md
architecture.md # updated cross-reference
how-to/
enable-grasp-06.md
reference/
configuration.md # NGIT_GRASP06_ENABLE entry
nix/
module.nix # grasp06Enable option, env wiring
plans/
grasp-06-implementation-plan.md # this file
```
---
## Things explicitly out of scope for this plan
- Tests — planned separately.
- NIP-98 or any HTTP-level auth on `/prs/`.
- Allowlist / quota / size cap / PoW. The config field exists for none of these; do not stub them.
- Deletion-request integration — on a separate branch.
- Object-pool dedup — later.
- Proactive sync / replication of `/prs/` repos between relays.
- Back-filling mirrors when an announcement is promoted after a `/prs/` push.
- Auto-mirroring standard-endpoint PRs into `/prs/` (the reverse direction).
- A `g`/`G` tag distinction in NIP-51 grasp-servers lists. Clients discover via NIP-11 `supported_grasps`.
---
## Verification checklist (for each phase)
Before marking a phase done:
- `cargo build` passes.
- `cargo clippy --all-targets -- -D warnings` passes.
- `cargo fmt --check` passes.
- No unrelated changes.
- If the phase added config: all four config-sync locations updated.
- If the phase touched architecture or added modules: the design doc reference is still accurate; update it if not.
---
## Open items to confirm with maintainers before implementation
1. The exact numeric kinds for PR (1618) and PR Update (1619). Cross-check against the `nostr-sdk` version in use and the in-tree `pr_event.rs` to be sure.
2. Whether the project already has a normalising `clone` tag URL comparator to match against `config.domain`. If not, implement one and use it from both the announcement side and the GRASP-06 relaxation.
3. Whether the shared empty-template approach or per-request temp-dir approach is preferred for fetch synthesis. Benchmark trivially during Phase 3 if unsure — both are small code.
4. Whether the PR purgatory entry's existing `SerializablePrPurgatoryEntry` versioning strategy accepts a new optional field via `#[serde(default)]` (it does for other optional fields — confirm by reading `src/purgatory/mod.rs`).