docs(explanation): document the GRASP-08 outbound policy matrix

The GRASP-08 design doc listed outbound authentication as follow-up
scope; the preceding commits implemented it, and architecture docs are
living documents that must describe what was built.

Replaces the follow-up bullet with an "Outbound authentication and
sync policy" section presenting the decided matrix: NIP-42 answered
everywhere with the relay owner key (skipped and immediately parked
without one), restricted refusals terminal via the policy-refusal
machinery, GRASP-08-advertising peers parked pre-dial on public
instances versus fully credentialed (NIP-42 plus repository-root
NIP-98 on Git fetches) on private ones, and missing NIP-11 treated as
an ordinary relay. Records the rationale that NIP-42 is identification
rather than confidentiality, and the derived-membership tightening to
GRASP-08-advertising relays. The architecture doc's GRASP-08 section
gains the matching outbound paragraph and membership caveat.

Doc-only change; multi-service fleet and encrypted kind-10318
discovery deliberately remain follow-up scope. Validated by reading
the rendered markdown against the implemented behavior in
src/sync/mod.rs, src/sync/relay_connection.rs, and
src/purgatory/sync/context.rs.
This commit is contained in:
DanConwayDev
2026-08-15 14:34:01 +00:00
parent 502c74c975
commit 9da74ac03f
2 changed files with 52 additions and 10 deletions
+15 -1
View File
@@ -716,7 +716,9 @@ single `PrivateAccess` set is shared by the HTTP and WebSocket services and
the announcement admission policy. Push authorization remains the GRASP-01
policy; a private credential proves service membership but never grants push
rights. The effective set combines operator-configured members with NIP-11
owner pubkeys learned for relays referenced by accepted announcements.
owner pubkeys learned for relays referenced by accepted announcements,
provided those relays' NIP-11 also advertises GRASP-08 (a public relay's
owner gains nothing legitimate from private membership).
Purgatory-only announcements are excluded. Reconciliation reuses the accepted
repository index and the NIP-11 fetch already performed once per connection
session, so private mode adds neither outbound connections nor subscriptions.
@@ -754,6 +756,18 @@ identity publication: the kind 0/10002 events are seeded and served locally
but never sent to the configured user-index relays, so a private relay does
not advertise its existence.
Outbound, every sync connection (public or private instance) answers NIP-42
challenges with the relay owner key when available; a `restricted:` refusal
after authentication parks the subscription via the existing policy-refusal
machinery. Peers advertising GRASP-08 in NIP-11 split by our own mode: a
public instance detects them with a pre-dial NIP-11 fetch and parks them
without ever opening the WebSocket, while a private instance treats them as
peers — NIP-42 on the WebSocket plus the GRASP-08 repository-root NIP-98
credential on purgatory Git fetches from that peer's host, both signed with
the relay owner key. Relays without a readable `supported_grasps` are
ordinary sync targets. See
[GRASP-08 design](grasp-08-private-service.md#outbound-authentication-and-sync-policy).
One process currently represents one private collaborator service. Operators
can run several independently configured instances for different groups. Fleet
provisioning and lifecycle automation are deliberately left to a later change;
+37 -9
View File
@@ -100,10 +100,11 @@ hints** (who to connect to), while the service-wide member set is the **ACL**
### Why accepted-relay owners are admitted dynamically
Two private services mirroring the same repository must be able to read from
each other. When an accepted announcement references another relay, that
relay's NIP-11 `pubkey` is added to the member set, so a peer service (or the
operator of an ordinary relay the team uses) can authenticate without manual
whitelisting on both sides. The consequence — accepting one announcement
each other. When an accepted announcement references another relay whose
NIP-11 also advertises `GRASP-08`, that relay's NIP-11 `pubkey` is added to
the member set, so a peer service can authenticate without manual
whitelisting on both sides. Owners of referenced *public* relays are not
admitted (see "Outbound authentication and sync policy" below). The consequence — accepting one announcement
grants its referenced relay operators read access to the whole service —
follows directly from the one-trust-domain model and is the operator's
opt-in via announcement admission.
@@ -160,7 +161,8 @@ lock a user out.
- **Accepted-relay owners** are derived members, trusted transitively via
announcement admission plus the referenced relay's NIP-11 self-assertion
(the same HTTPS-from-domain trust anchor a `_@domain` NIP-05 lookup would
provide, without an extra fetch or format).
provide, without an extra fetch or format). Only relays whose NIP-11 also
advertises `GRASP-08` qualify.
- **Membership grants read access only.** Push authorization remains
GRASP-01's maintainer model; repository admission remains announcement
policy, which in private mode additionally requires the announcement
@@ -169,13 +171,39 @@ lock a user out.
invalidates future credentials, but repositories admitted while they were a
member remain hosted until the operator curates them.
## Outbound authentication and sync policy
The outbound half of private-service support decides how this instance, as a
*client*, treats the relays it syncs from:
| Situation | Behavior |
| --- | --- |
| Any relay issues a NIP-42 challenge | Answer with the relay owner key (public and private instances alike). The SDK retries the refused subscription once after authenticating. Without an owner key, authentication is skipped and auth-demanding subscriptions park immediately. |
| A relay answers `restricted:` after valid authentication | Terminal: the subscription parks through the policy-refusal machinery (24-hour probe), no retry storm. |
| Peer NIP-11 advertises `GRASP-08`, this instance is **public** | Not a sync target at all: detected by a pre-dial NIP-11 fetch and parked without ever opening the WebSocket, so no AUTH exchange happens and no credential could leak. |
| Peer NIP-11 advertises `GRASP-08`, this instance is **private** | A peer: NIP-42 on the WebSocket *plus* the GRASP-08 repository-root NIP-98 credential attached to purgatory Git fetches from that peer's host, both signed with the relay owner key. |
| NIP-11 missing, unreadable, or without `supported_grasps` | An ordinary relay. |
The pre-dial NIP-11 fetch re-runs the outbound target policy for
event-directed URLs first, so the SSRF gate covers it like the dial itself.
**Why NIP-42 everywhere?** NIP-42 is identification, not confidentiality. A
gated relay admitting our pubkey grants a *known* service read access — our
pubkey is already published via NIP-11 and the NIP-05 root identity. A
private instance authenticating outbound discloses its identity to the relays
it syncs from, which is consistent with GRASP-08's public-discovery stance:
private mode hides repository content, not the service.
**Why derived membership requires GRASP-08 (see above)?** For the same
asymmetry: a public relay's owner gains nothing legitimate from private
membership, because their relay enforces no confidentiality for the
repositories it mirrors. Only relays advertising `GRASP-08` in their NIP-11
`supported_grasps` mint derived members; configured `NGIT_PRIVATE_MEMBERS`
are unaffected.
## Follow-up scope
Deliberately excluded from the initial single-service implementation:
- **Outbound authentication**: presenting NIP-42 and GRASP-08 NIP-98
credentials when syncing *from* other private services, using a service
identity key. This is the missing half of zero-configuration private
mirroring.
- **Multi-service fleet orchestration** and **encrypted kind-10318 client
discovery**, which belong to future GRASP proposals.