Move current GRASP specification links from GitHub to GitWorkshop. Link audit output to its exact pinned specification commit. Use NIP-05 cloning for ngit-grasp and ngit.dev for the ngit homepage. Leave the archived migration link unchanged as a historical record. Validated with rustfmt and the 54-test grasp-audit library suite.
21 KiB
GRASP-06: Contributor Pull Request Submission — Design
Status: Implemented (opt-in via NGIT_GRASP06_ENABLE)
Spec: GRASP-06 Related: Purgatory Design, Architecture, Inline Authorization Operator how-to: Enable GRASP-06
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
- New URL namespace:
/prs/<npub>/<identifier>.git(git smart HTTP, unauthenticated). - 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
clonetag names our/prs/<signer>/<identifier>.gitendpoint. - Cross-service mirroring: when a PR or PR Update is released from purgatory via the
/prs/endpoint, itsrefs/nostr/<event-id>is synced into any accepted repository announcements on this relay whose coordinate appears in the event'satags.
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:
- Intent check: it proves the contributor explicitly chose this relay as a destination, not that we happen to match a naming pattern by accident.
- Preventing replay/fan-out abuse: without the
clonetag, 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.
Identifier-family object storage makes the mirror a ref-only operation. Owner and contributor views for the same identifier resolve one shared object inventory, so accepting or mirroring a PR does not copy its object graph.
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; retain family tip; 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 and the PR purgatory entries in src/purgatory/types.rs) — it just skips the authorize_push path that checks the maintainer set, and applies the spec's validation invariant instead.
On-demand bare repo creation
The first push to /prs/<submitter>/<identifier>.git creates a thin bare view
whose alternate is the identifier family. A per-(submitter, identifier)
PrsPathState still protects view creation and
cleanup. A separate per-family write lease serializes object-producing work
across all owner and contributor views for that identifier. git-receive-pack
writes objects to the family while updating only the selected view's refs.
Off-push cleanup may remove an empty contributor view, but never removes family
objects or retained roots.
Event acceptance relaxation
The existing PR event policy in 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
atag, - and a
clonetag 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.
The clone-URL match is implemented in src/grasp06/policy.rs as a strict comparator: it requires http/https scheme, an exact (case-insensitive) authority match against config.domain, no query string or fragment, exactly two path segments <npub-segment>/<repo-segment>.git, the npub segment decoding via PublicKey::from_bech32 to the event's signer, and the percent-decoded identifier matching one of the event's a-tag <d> values. Anything else fails the relaxation and the event falls through to the existing rejection path.
Cross-service mirror
When a PR or PR Update's purgatory entry is released via a /prs/ push:
- Save event to DB and remove from purgatory (as today).
- For each
atag in the event of the form30617:<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, install
refs/nostr/<event-id>in its view after checking the commit resolves through the shared family.
- Resolve to a local repo path
The mirror installs the same ref to the same commit. Git alternates keyed by identifier make the objects immediately available without a copy.
The mirror is one-directional: pushes to /<maintainer>/<id>.git are not mirrored into /prs/*. Only the /prs/ → <maintainer>/ direction fires, and only when the source repo path is under prs_base_path.
The mirror also does not back-fill retroactively. If an announcement for one of the event's a coords is accepted after a matching /prs/ push has already happened, the ref remains only at /prs/<signer>/<d>.git; clients can still fetch it via the event's clone tag. Back-filling on announcement promotion is deferred — the simplest correct shape ships first, and the spec allows clients to resolve the PR through /prs/ indefinitely.
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_prcall, waits for any push that materialisesrefs/nostr/<event-id>with matching commit — at either the standard endpoint or/prs/. - Git-first: push arrives at
/prs/<npub>/<id>.git, normaladd_pr_placeholdercall 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): skips<git_data_path>/prs/*viais_prs_repo_pathbefore recursing, so contributor-submission repos cannot be misreported as orphans. - Repo landing pages (
src/http/mod.rs::parse_repo_url): refuses any path starting with/prs/as a defensive guard, regardless ofgrasp06_enable. TheHttpServicerouting also intercepts/prs/*earlier when the feature is on. - Proactive sync (GRASP-02):
/prs/repos are not replicated between relays. The proactive-sync subsystem derives every subscription from the DB-resident announcement set;/prs/repos have no announcement and so are excluded by construction. No filesystem walk discovers them. A GRASP-06 relay is authoritative for the PRs it accepts. Clients that need a PR should fetch it from the relay the event'sclonetag names. - Repo listings / NIP-11:
/prs/repos are not advertised as repositories. They are a submission side-channel, not first-class hosted repos. GRASP-06 itself is advertised in the relay's NIP-11supported_graspslist when the flag is on.
Zero-ref /prs/ cleanup
A /prs/<submitter>/<identifier>.git bare repo can become zero-ref through three runtime paths — each cleaned up inline at the site where the last ref is removed — plus a startup recovery pass that catches anything left behind by a previous run. There is no periodic sweep over <git_data_path>/prs/:
- Probe push leaves no valid refs. The
/prs/receive handler validates each pushedrefs/nostr/<event-id>against the database and purgatory. If every ref fails validation, the bare repo is empty at the end of the push. The handler removes it before returning, under a brief end-of-push critical section on the per-path mutex (the mutex is not held acrossgit-receive-packitself — only across the init+register and decrement+cleanup windows). - Scoped placeholder fails validation against a later-arriving event. When a PR event arrives whose
(signer, identifier, commit)does not match the placeholder a/prs/push registered, the PR-event policy (src/nostr/policy/pr_event.rs) takes the per-path mutex, deletes the correspondingrefs/nostr/<event-id>ref, and discards the placeholder. It removes the bare directory only whenin_flight == 0(no push is currently mid-receive) andlist_refsreports empty. - Scoped placeholder expires without a matching event. The standard purgatory sweep (
src/purgatory/mod.rs, every 60 seconds) walks expiringPrPurgatoryEntryrows. For entries with aprs_scope, the sweep best-effort deletes the danglingrefs/nostr/<event-id>ref and — under the samein_flight == 0guard — the bare repo itself. The sweep is synchronous, so it usestry_lockon the per-path mutex: if a push is briefly holding the mutex (during init or end-of-push), the cleanup is skipped this cycle. In the worst case a dangling ref is left on disk; this is harmless because any future push to the same path simply ignores it, and the next purgatory sweep will retry. - Startup recovery. Before the HTTP server starts accepting requests,
src/grasp06/cleanup.rs::scan_on_startupwalks<git_data_path>/prs/<hex>/<id>.gitonce and removes any bare repo with zero refs, plus any submitter directories left empty as a result. This catches dirs left behind by a previous run — crashes mid-push, crashes mid-cleanup (betweendelete_refandremove_dir_all), or clean shutdowns with unresolved scoped placeholders whose in-memory state was lost. Nothing is in flight at startup so no locking is needed; the samelist_refs/remove_dir_allshape as the runtime paths applies. Gated ongrasp06_enable— operators who have turned the feature off do not have their existing/prs/data scanned.
The shared lock map is wired through:
HttpServiceholds it asrepo_init_locksand passes it into every/prs/request.PolicyContextholds the sameArc<DashMap<…>>so the PR-event policy can lock without going through HTTP.Purgatory::set_prs_cleanup_ctxwires the lock map (plusgit_data_path) into purgatory at startup so the expiry sweep has everything it needs to act on a scoped placeholder.
Off-push deletion paths and the receive handler therefore share a single source of truth for "is a push currently in flight to this (submitter, identifier)" — the per-path in_flight counter, read under the same mutex that gates its updates.
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, defaultfalse) — 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>.gitand removes the event from the event DB. - The origin ref at
/prs/<contributor-npub>/<id>.gitsurvives — 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
The identifier family is the shared object inventory. Mirroring installs a view ref and does not copy objects; 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