25 KiB
GRASP-06 Implementation Plan
Target spec: GRASP-06 (draft)
Design doc: 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
- 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. - Do not regress the standard
/<npub>/<identifier>.gitendpoint. Its auth model (authorize_push/ maintainer set) is unchanged. - 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 insrc/git/sync.rsalready cover most of this feature. The new code path is thin on top. - Gate everything behind
NGIT_GRASP06_ENABLE. When disabled,/prs/*returns 404 and the event-acceptance relaxation does not apply. Default must befalse. - 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. - Follow the existing style. Config env var naming (
NGIT_GRASP06_*),AGENTS.mdconfig-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.
- Config + feature flag
- Disk layout + path helpers
- HTTP routing and empty-repo fetch synthesis
/prs/receive-pack handler- Purgatory placeholder scoping
- Event-acceptance relaxation in PR policy
- Cross-service mirror on purgatory release
- Exclusion from cleanup / sync / listings
- NIP-11 advertisement
- 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:/// 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— addgrasp06EnabletoinstanceOptionsasmkOption { type = types.bool; default = false; description = "..."; }and wire it into the environment block asNGIT_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:
//! 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";andpub const PRS_DISK_PREFIX: &str = "prs";(spec-fixed). pub fn prs_repo_path(git_data_path: &Path, submitter_hex: &str, identifier: &str) -> PathBufreturning<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) -> PathBufreturning<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). Rejectsprsas a literal first component if<npub>slot doesn't start withnpub1.
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:
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:
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 insrc/git/handlers.rs(handle_info_refs,handle_upload_pack). Consider refactoring those to accept aPathBufdirectly if they don't already; the current signatures should be fine. - Otherwise → synthesize an empty-repo response.
To synthesize:
git init --bareinto a temp dir (tempfile crate, already a dep).- Run
git-upload-packorgit-http-backend-equivalent against the temp dir. - 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:
- No
authorize_pushcall. Skip the maintainer-set check entirely — that's the whole point of this endpoint. - Pre-scan the push body using
parse_pushed_refs(already exists insrc/git/authorization.rs— lift it intocrate::git::pub useif it isn't already public). Reject the entire push with an ERR pkt-line if any ref is notrefs/nostr/<64-hex-event-id>. - Create the real repo at
prs_repo_path(...)withgit init --bare --initial-branch=mainbefore runninggit-receive-pack. Usestd::fs::create_dir_allfor the parent — it's idempotent. Use a per-(submitter_hex, identifier) mutex aroundgit initto avoid racing two simultaneous first pushes. ADashMap<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). - Run
git-receive-packusing the existingGitSubprocesshelpers. - Post-push processing: parse
pushed_refs(new_oids for eachrefs/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.
- Validate against the DB first: if a matching PR/PR-Update event exists in the DB, check signer/d-tag/commit. On commit mismatch,
- Repo cleanup: after processing, count refs in the repo with
list_refs. If zero,std::fs::remove_dir_allthe repo directory. This discards probe-pushes that wrote nothing valid. - Trigger release: call
process_newly_available_git_dataon 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:
/// 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:
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_scopeisNone(standard flow) → existing behaviour (commit match is sufficient). - If
prs_scopeisSome(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
atag in the event has d-tag== scope.identifier. On mismatch, same treatment as above. - If both pass, proceed as normal.
- Verify
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, extendPrPurgatoryEntrywith afrom_grasp06: bool(#[serde(default)]) or mirror the placeholder scoping by allowing an optionalprs_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.tagslooking foratags of the form30617:<hex-pubkey>:<d-tag>. - For each
atag, 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), mirrorrefs/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 existingsync_oids/sync_refshelpers invoked today when the samerefs/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
.gitrepo 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:
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.
-
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. -
Update
docs/explanation/architecture.mdto mention the/prs/endpoint as an optional subsystem, with a pointer to the GRASP-06 design doc. -
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. -
Update
CHANGELOG.mdunder a new unreleased section with a user-facing summary. -
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/Gtag distinction in NIP-51 grasp-servers lists. Clients discover via NIP-11supported_grasps.
Verification checklist (for each phase)
Before marking a phase done:
cargo buildpasses.cargo clippy --all-targets -- -D warningspasses.cargo fmt --checkpasses.- 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
- The exact numeric kinds for PR (1618) and PR Update (1619). Cross-check against the
nostr-sdkversion in use and the in-treepr_event.rsto be sure. - Whether the project already has a normalising
clonetag URL comparator to match againstconfig.domain. If not, implement one and use it from both the announcement side and the GRASP-06 relaxation. - 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.
- Whether the PR purgatory entry's existing
SerializablePrPurgatoryEntryversioning strategy accepts a new optional field via#[serde(default)](it does for other optional fields — confirm by readingsrc/purgatory/mod.rs).