From 3880529d1167a01520e257b339bd79eeee21565a Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Mon, 27 Jul 2026 13:55:54 -0400
Subject: [PATCH 01/10] docs(nips): specify kind:30621 multi-repo projects
 (NIP-MP)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Buzz renders one card per kind:30617, so a project spanning several
repositories has no representation. Membership cannot live in the
repository events themselves: a project spanning two owners' repositories
would need both owners to publish a tag naming the group, and no signer
can sign for another's key. Project-level name, description, and channel
binding likewise have no single writer when scattered per repository.

NIP-MP defines one addressable container event that holds all group
state, references members by coordinate, and grants its signer zero
authority over any member — push policy continues to read only the
repository's own announcement.

The fixture file is the shared accept/reject contract for the relay
validator and both event builders, so a divergence between them fails a
test rather than reaching production. Cases carry unsigned templates
because an id and signature are fixed by exact serialization and would
not survive a consumer re-serializing.

VISION_PROJECTS.md said "zero custom kinds"; that now reads "no custom
kind for the repo itself", with the reasoning for the single exception
recorded alongside it.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 VISION_PROJECTS.md             |   34 +-
 docs/nips/NIP-MP.fixtures.json | 1331 ++++++++++++++++++++++++++++++++
 docs/nips/NIP-MP.md            |  263 +++++++
 3 files changed, 1627 insertions(+), 1 deletion(-)
 create mode 100644 docs/nips/NIP-MP.fixtures.json
 create mode 100644 docs/nips/NIP-MP.md

diff --git a/VISION_PROJECTS.md b/VISION_PROJECTS.md
index a44d7e05f7..8601b87829 100644
--- a/VISION_PROJECTS.md
+++ b/VISION_PROJECTS.md
@@ -38,12 +38,42 @@ Branch protections live in the same event — `buzz-protect` tags. The relay enf
 
 Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly.
 
-Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds.
+Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, no custom kind for the repo itself.
 
 NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable.
 
 ---
 
+## One Project, Many Repos
+
+Real work spans repositories. The platform is a relay, a desktop app, and a mobile app — three repos, one project. Render one card per repo and they look like three unrelated things.
+
+Grouping is the one forge semantic that per-repo tags cannot express, and it's worth being precise about why, because everything else here deliberately avoids a custom kind.
+
+Put membership in each `kind:30617` and a project spanning Alice's and Bob's repos needs *both* of them to publish a tag naming the group. Alice can't enroll Bob's repo — she can't sign for his key. Cross-owner grouping becomes impossible, and the project's own name, description, and channel end up scattered across events with no single writer and no deletion story: dropping a repo from the group would mean editing an event you don't control.
+
+So there is exactly one custom kind — [NIP-MP](docs/nips/NIP-MP.md), `kind:30621`. One signer, one replaceable event, all group state in one place:
+
+```json
+{
+  "kind": 30621,
+  "tags": [
+    ["d", "platform"],
+    ["name", "Platform"],
+    ["a", "30617:<alice-npub-hex>:buzz"],
+    ["a", "30617:<bob-npub-hex>:buzz-infra"],
+    ["buzz-channel", "<channel-uuid>"],
+    ["buzz-visibility", "listed"]
+  ]
+}
+```
+
+A project points at repos. That's all it does. The signer gets no authority over any member — no edit, no delete, no push, no admin. Adding Bob's repo to your project is your signed assertion that the two belong together, and it changes nothing about Bob's repo or who can push to it. Push policy reads the repo's own event, never the project's.
+
+The cost is stated plainly: a third-party NIP-34 client sees the member repos individually and ignores the grouping. Nothing degrades — the repos are still standard, portable `kind:30617` events. And a repo in no project still renders on its own, exactly as before.
+
+---
+
 ## Branches as Channels
 
 A feature branch is a conversation.
@@ -205,6 +235,7 @@ Standard kinds as substrate. Custom kinds only where genuinely novel.
 | **Workflows** | — | 46001-46012 | No NIP equivalent |
 | **Job dispatch** | — | 43001-43006 | Delegation trees |
 | **Project binding** | 30617 (NIP-34) | `buzz-` tags | Channel, visibility |
+| **Multi-repo projects** | — | 30621 ([NIP-MP](docs/nips/NIP-MP.md)) | Cross-owner grouping is unexpressible in per-repo tags |
 | **Audit** | — | 48001 | Hash-chain tamper-evident log |
 
 If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol.
@@ -221,6 +252,7 @@ If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patc
 | Blossom media storage (SHA-256, S3) | ✅ Ships today |
 | Approval gates | 🚧 Infrastructure exists; executor wiring in progress |
 | Project binding (kind:30617 + `buzz-` tags) | 📋 Designed |
+| Multi-repo projects (kind:30621, [NIP-MP](docs/nips/NIP-MP.md)) | 📋 Designed |
 | Git hosting (smart HTTP + NIP-34) | ✅ Ships today |
 | Merge coordinator | 📋 Designed |
 | NIP-34 issues (kind:1621) | 📋 Designed |
diff --git a/docs/nips/NIP-MP.fixtures.json b/docs/nips/NIP-MP.fixtures.json
new file mode 100644
index 0000000000..89b7fa4166
--- /dev/null
+++ b/docs/nips/NIP-MP.fixtures.json
@@ -0,0 +1,1331 @@
+{
+  "$comment": "NIP-MP conformance fixtures \u2014 the shared contract for the relay ingest validator, the Rust event builder, and the TypeScript event builder. Each case carries an UNSIGNED template: consumers sign it with their own test key, because signing fixes the event id and signature and a stored literal would not survive re-serialization. `expect` is the ingest outcome. `reject_rules` lists the validation rules that may fire; an implementation must reject for one of them, so it cannot pass by rejecting for an unrelated reason. Spec: docs/nips/NIP-MP.md.",
+  "version": 1,
+  "kind": 30621,
+  "member_cap": 64,
+  "owners": {
+    "a": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "b": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+  },
+  "cases": [
+    {
+      "name": "valid_minimal",
+      "expect": "accept",
+      "note": "Only the identity tag. A project needs nothing but a `d` tag to be a valid addressable container.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_full",
+      "expect": "accept",
+      "note": "Every specified tag present, with two members owned by two different pubkeys \u2014 the cross-owner grouping that motivates the kind.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "name",
+            "Platform"
+          ],
+          [
+            "description",
+            "Relay, desktop, and mobile for the platform team."
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          [
+            "a",
+            "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-infra"
+          ],
+          [
+            "buzz-channel",
+            "3580ca9b-47b4-4af9-b22a-1068778f26c6"
+          ],
+          [
+            "buzz-visibility",
+            "listed"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_zero_members",
+      "expect": "accept",
+      "note": "Zero-member project. Legal at the protocol layer: it is the natural state after removing a final member and carries only bounded metadata.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "name",
+            "Platform"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_unlisted",
+      "expect": "accept",
+      "note": "Explicitly unlisted container. Accepted at ingest; the fold excludes it from listing eligibility, so its member keeps its implicit card.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "skunkworks"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          [
+            "buzz-visibility",
+            "unlisted"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_member_cap_boundary",
+      "expect": "accept",
+      "note": "Exactly 64 distinct member `a` tags \u2014 the cap is inclusive. Paired with `invalid_member_cap_exceeded` to pin the boundary from both sides.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "wide"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_same_dtag_two_owners",
+      "expect": "accept",
+      "note": "Two members share a repo `d` segment under different owners (the NIP-34 fork case). Identity is the whole coordinate, so these are not duplicates.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "forks"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          [
+            "a",
+            "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_member_dtag_contains_colon",
+      "expect": "accept",
+      "note": "Repo `d` segment contains a colon. The coordinate splits into at most three parts, so the third part is the repo `d` value verbatim \u2014 `buzz:infra` here. Splitting on every colon instead would make any repo with a colon in its `d` tag unaddressable by a project.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz:infra"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_uninterpreted_metadata_values",
+      "expect": "accept",
+      "note": "`buzz-channel` is not a UUID and `buzz-visibility` is an unrecognized token. Ingest bounds metadata cardinality and length but does not interpret these values \u2014 matching how kind:30617 carries the same two tags. A client that cannot resolve the channel renders the project without one; an unrecognized visibility is treated as `listed`.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "buzz-channel",
+            "not-a-uuid"
+          ],
+          [
+            "buzz-visibility",
+            "chartreuse"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_unknown_tag_ignored",
+      "expect": "accept",
+      "note": "An unrecognized tag is neither rejected nor interpreted \u2014 unknown tags are ignored, not fatal.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "future-metadata",
+            "preserve-me"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_d_missing",
+      "expect": "reject",
+      "note": "No `d` tag. Without one the event collapses into the `(pubkey, 30621, \"\")` slot and silently last-write-wins over an unrelated project.",
+      "reject_rules": [
+        "d-cardinality"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "name",
+            "Platform"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_d_multiple",
+      "expect": "reject",
+      "note": "Two `d` tags. Which one addresses the event is reader-dependent; reject rather than pick.",
+      "reject_rules": [
+        "d-cardinality"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "d",
+            "infra"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_d_empty",
+      "expect": "reject",
+      "note": "Present but empty `d`. Same slot-collapse hazard as a missing `d`.",
+      "reject_rules": [
+        "d-empty"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            ""
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_duplicate",
+      "expect": "reject",
+      "note": "The same canonical coordinate twice. A signed event cannot be normalized in place, so the relay refuses it rather than store a head every consumer must defensively dedupe.",
+      "reject_rules": [
+        "member-duplicate"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_cap_exceeded",
+      "expect": "reject",
+      "note": "65 distinct member `a` tags \u2014 one past the inclusive cap of 64.",
+      "reject_rules": [
+        "member-cap"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "wide"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-64"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_cap_exceeded_by_duplicates",
+      "expect": "reject",
+      "note": "65 member `a` tags naming 33 distinct coordinates. The spec evaluates the cap before the duplicate set is built, so `member-cap` is the required rule even though the event also carries duplicates; a validator that reports `member-duplicate` here has built a set whose size is bounded only by the frame limit.",
+      "reject_rules": [
+        "member-cap"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "wide"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_kind_prefix",
+      "expect": "reject",
+      "note": "Coordinate names kind 30618 (repo state) rather than 30617 (repo announcement). A project groups repositories, not their ref state.",
+      "reject_rules": [
+        "member-coordinate-malformed"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30618:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_owner_not_hex",
+      "expect": "reject",
+      "note": "Owner segment is 64 characters but not hex.",
+      "reject_rules": [
+        "member-coordinate-malformed"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz:buzz"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_owner_uppercase",
+      "expect": "reject",
+      "note": "Owner segment is uppercase hex. `#a` tag matching is byte-exact, so an uppercase head is invisible to the lowercase-coordinate queries every reader issues.",
+      "reject_rules": [
+        "member-coordinate-malformed"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_owner_wrong_length",
+      "expect": "reject",
+      "note": "Owner segment is 63 hex characters.",
+      "reject_rules": [
+        "member-coordinate-malformed"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_dtag_empty",
+      "expect": "reject",
+      "note": "Coordinate has an empty repo `d` segment \u2014 it addresses no announceable repository.",
+      "reject_rules": [
+        "member-coordinate-malformed"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_member_missing_segment",
+      "expect": "reject",
+      "note": "Coordinate has two segments instead of three.",
+      "reject_rules": [
+        "member-coordinate-malformed"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_metadata_duplicate_name",
+      "expect": "reject",
+      "note": "Two `name` tags. Reader-dependent display name; reject rather than pick.",
+      "reject_rules": [
+        "metadata-cardinality"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "name",
+            "Platform"
+          ],
+          [
+            "name",
+            "Infra"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_metadata_duplicate_channel",
+      "expect": "reject",
+      "note": "Two `buzz-channel` tags. Which channel the project links to would be reader-dependent.",
+      "reject_rules": [
+        "metadata-cardinality"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "buzz-channel",
+            "3580ca9b-47b4-4af9-b22a-1068778f26c6"
+          ],
+          [
+            "buzz-channel",
+            "00000000-0000-0000-0000-000000000000"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_metadata_name_too_long",
+      "expect": "reject",
+      "note": "`name` value exceeds 256 bytes.",
+      "reject_rules": [
+        "metadata-length"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "name",
+            "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_metadata_description_too_long",
+      "expect": "reject",
+      "note": "`description` value exceeds 2048 bytes.",
+      "reject_rules": [
+        "metadata-length"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "description",
+            "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+          ]
+        ]
+      }
+    }
+  ]
+}
diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
new file mode 100644
index 0000000000..ccaf7fb3d1
--- /dev/null
+++ b/docs/nips/NIP-MP.md
@@ -0,0 +1,263 @@
+NIP-MP
+======
+
+Multi-Repository Projects
+-------------------------
+
+`draft` `optional` `relay`
+
+**Depends on**: NIP-01 (basic event format), NIP-33 (parameterized replaceable events), NIP-34 (git repositories), NIP-09 (event deletion). Interacts with NIP-29 (the channel a project links to) and NIP-OA (owner attestation, for how agents inherit repo push access).
+
+## Abstract
+
+This NIP defines `kind:30621`, an addressable **project** event: a signed, named grouping of NIP-34 repository announcements (`kind:30617`). A project references its member repositories by coordinate, so one project may span repositories owned by different pubkeys, and one repository may belong to several projects.
+
+A project is metadata only. Its signer gains no authority over any member repository — not to edit it, delete it, push to it, or administer it. Membership is an assertion about grouping, not a grant of permission.
+
+## Motivation
+
+Buzz renders one card per `kind:30617`, so "the platform" — a relay, a desktop app, and a mobile app — appears as three unrelated repositories. Real work spans repositories; the model does not.
+
+[VISION_PROJECTS.md](../../VISION_PROJECTS.md) sets the bar as "standard kinds as substrate, custom kinds only where genuinely novel," and every other forge concept in Buzz clears it: repositories, patches, issues, statuses, and ref state are all standard NIP-34 kinds. Multi-repository grouping is the one semantic that cannot be:
+
+- **Per-repository tags cannot express cross-owner grouping.** If membership lived in each `kind:30617`, a project spanning Alice's and Bob's repositories would require *both* Alice and Bob to publish a tag naming the group. Alice cannot enroll Bob's repository; she cannot sign for his key. Grouping would be possible only within a single owner's repositories, and would break the moment a repository changed hands or a fork joined.
+- **Project-level metadata has no owner.** A project name, description, and linked channel describe the *group*, not any one repository. Scattered across per-repository tags they have no single writer, no replacement semantics, and no deletion story: removing a repository from the group means editing an event you may not control.
+- **Existing list kinds do not fit.** NIP-51 sets (`kind:30004` curation sets and friends) are private-or-public user bookmarks over arbitrary content, not a shared, named, addressable container for a forge collection with its own channel binding and visibility. Overloading a curation set would make every project indistinguishable from a user's reading list.
+
+One custom kind, held by one signer, with all group state in one replaceable event, resolves all three. The cost is bounded and stated plainly: `kind:30621` is Buzz-specific, so a third-party NIP-34 client sees the member repositories individually and ignores the grouping. Nothing degrades — the repositories remain standard, portable `kind:30617` events, discoverable and renderable exactly as before.
+
+## Non-Goals
+
+This NIP does not define shared or delegated project editing — a project is replaceable only by its own signer (see [Authority](#authority)).
+This NIP does not define any authorization over member repositories. Membership is not a permission grant, and a project is never consulted by git push policy.
+This NIP does not define project-level branch protection, CI, or workflow configuration.
+This NIP does not define nested projects. A project's members are repositories, never other projects.
+This NIP does not require relays to verify that a member coordinate resolves to an existing repository — a project may reference a repository that does not exist yet, or no longer does.
+
+## Terminology
+
+This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as defined in RFC 2119.
+
+- **project**: A `kind:30621` event. Also called the *container*.
+- **member**: A repository referenced by a project, named by an `a` tag holding a repository coordinate.
+- **coordinate**: The NIP-33 address of a repository announcement, `30617:<owner-pubkey-hex>:<repo-d-tag>`.
+- **explicit project**: A project that exists as a `kind:30621` event.
+- **implicit project**: The single-repository card a client renders for a `kind:30617` that no listing-eligible explicit project claims. Not an event — a rendering fallback.
+- **listing eligible**: A project a client is currently rendering in its project collection. See [Listing eligibility](#listing-eligibility).
+
+## Kinds
+
+| Kind | Name | Signer | Class | Purpose |
+|------|------|--------|-------|---------|
+| `30621` | Project | user | parameterized replaceable | A named grouping of `kind:30617` repository announcements |
+
+`kind:30621` is parameterized replaceable per NIP-33 (`30000 <= n < 40000`), addressed by `(pubkey, 30621, d)`. Two signers may use the same `d` value; those are two distinct projects.
+
+### Kind allocation
+
+`30621` sits in the NIP-34 git block (`30617` repository announcement, `30618` repository state), which is where a reader looks for a forge concept. Checks performed before freezing the number:
+
+| Registry | Checked | Result |
+|----------|---------|--------|
+| Upstream nostr NIPs event-kind table (`nostr-protocol/nips` `README.md`, at commit `6d2979b3f503a8539c983efbcdcf901bbcf9ed23`) | `30610`–`30629` | Only `30617` and `30618` are assigned. `30621` is unassigned. |
+| nostrbook.dev kind registry (`https://nostrbook.dev/kinds/<n>`) | `30617`, `30618`, `30620`, `30621`, `30622` | `30617` and `30618` documented (HTTP 200). `30620`, `30621`, `30622` all HTTP 404 — no entry. |
+| This repository (`crates/buzz-core/src/kind.rs`) | full range | `30620` is `KIND_WORKFLOW_DEF`, `30622` is `KIND_DM_VISIBILITY` (NIP-DV). `30621` is the one free number between them. |
+
+Both external registries are advisory, not authoritative allocators: neither reserves numbers, and an unregistered kind may still be in use by an unpublished client. A future upstream assignment of `30621` would be a collision Buzz absorbs the same way it already does for its other custom kinds — the number is Buzz-specific, and interoperability rests on the member `kind:30617` events, which remain standard.
+
+## Event Format
+
+```jsonc
+{
+  "kind": 30621,
+  "pubkey": "<project-signer-pubkey-hex>",
+  "content": "",
+  "tags": [
+    ["d", "platform"],
+    ["name", "Platform"],
+    ["description", "Relay, desktop, and mobile for the platform team."],
+    ["a", "30617:<owner-a-pubkey-hex>:buzz"],
+    ["a", "30617:<owner-b-pubkey-hex>:buzz-infra"],
+    ["buzz-channel", "<channel-uuid>"],
+    ["buzz-visibility", "listed"]
+  ]
+}
+```
+
+| Tag | Cardinality | Meaning |
+|-----|-------------|---------|
+| `d` | exactly 1, non-empty | Project slug. The NIP-33 identifier. |
+| `name` | 0 or 1 | Human-readable display name. Clients fall back to `d` when absent. |
+| `description` | 0 or 1 | Free text describing the project. |
+| `a` | 0 to 64 | One member repository coordinate each. Order is not significant. |
+| `buzz-channel` | 0 or 1 | UUID of the channel this project's discussion lives in. Metadata only — see [Authority](#authority). |
+| `buzz-visibility` | 0 or 1 | `listed` (default) or `unlisted`. Feeds [listing eligibility](#listing-eligibility). |
+
+`content` is empty and carries no meaning. Clients and relays MUST NOT parse semantics from it.
+
+Unrecognized tags MUST be ignored rather than rejected, so a newer writer can add metadata without invalidating its events for older readers.
+
+### Member coordinates
+
+A member `a` tag value MUST be exactly `30617:<owner>:<repo-d>` where:
+
+- the kind segment is the literal `30617`. A project groups repository *announcements*; a coordinate naming any other kind (notably `30618` repository state) is malformed.
+- `<owner>` is 64 lowercase hex characters. Uppercase is rejected: `#a` filter matching is byte-exact, so an uppercase-owner head would be invisible to the lowercase-coordinate queries every reader issues.
+- `<repo-d>` is non-empty and is the `d` tag of the member repository announcement, taken **verbatim**.
+
+Parsing splits on the first two colons only; everything after the second colon is `<repo-d>`. A repository whose `d` tag contains a colon is therefore addressable. Splitting on every colon would make such a repository permanently unaddressable by any project.
+
+Buzz-hosted repositories cannot currently produce such a coordinate: their `d` values are validated as `[a-zA-Z0-9._-]{1,64}` (`crates/buzz-relay/src/handlers/side_effects.rs`, `crates/buzz-sdk/src/builders.rs`). The tolerance is for the repositories this NIP does not control — NIP-34 announcements from other clients, and any future relaxation of Buzz's own rule — and it matches how Buzz already parses coordinates in NIP-09 deletion handling, so a project coordinate and a deletion coordinate can never disagree about where a repository's `d` value begins.
+
+Coordinate identity is the whole string. Two members sharing a `<repo-d>` under different owners — the NIP-34 fork case — are distinct members, not duplicates.
+
+A project MAY reference a coordinate that resolves to nothing: a repository not yet announced, deleted, or announced on another relay. Clients render those members as explicitly unavailable ([Client Behavior](#client-behavior), step 6).
+
+## Semantics
+
+### Authority
+
+The project signer's authority begins and ends at the container.
+
+- **Over the container**: total. Only the signer can replace or delete their `(pubkey, 30621, d)` coordinate.
+- **Over member repositories**: none. No edit, no delete, no push, no administration, no ability to change a member repository's own metadata or protections. Adding Bob's repository to Alice's project changes nothing about Bob's repository or who may push to it. It is Alice's signed assertion that the two belong together, and it is attributable to her key.
+
+Clients MUST preserve each member repository's own owner provenance in the UI. A repository rendered inside a project must not appear to be owned or governed by the project signer.
+
+`buzz-channel` on a project is **metadata only**. Git push policy reads the `buzz-channel` of the repository's own `kind:30617` (`crates/buzz-relay/src/api/git/policy.rs`); a project neither overrides that binding nor supplies one to a member that lacks it. A project's channel binding therefore cannot widen or narrow push access to anything.
+
+### Editing model
+
+Editing is **owner-only**: publish a replacement `kind:30621` with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-33 replacement already refuses to let one pubkey overwrite another's coordinate.
+
+Delegated or maintainer editing is deliberately out of scope for this version. Adding it later needs no change to this event shape — only a new rule about who may replace a coordinate.
+
+### Zero-member projects
+
+A project with no `a` tags is valid. It is the natural state after removing a final member, and it carries only bounded metadata either way. Deleting the container — with its name, description, and channel binding — because its last repository was removed would be a destructive surprise for a reversible action.
+
+Clients SHOULD require at least one member when *creating* a project, since an empty new project is almost always a mistake, and MUST render an existing empty project as an empty container rather than hiding it or treating it as malformed.
+
+### Multiple membership
+
+A repository may be a member of any number of projects. It renders inside each ([Client Behavior](#client-behavior), step 4). Membership is not exclusive and not a move: nothing about the repository event changes when it joins or leaves a project.
+
+### Deletion
+
+Deleting a project (NIP-09 `kind:5` naming the project coordinate) deletes the `kind:30621` only. Member repositories are untouched — their `kind:30617` events, refs, channels, and protections all survive, and each falls back to an implicit card unless another listing-eligible project claims it.
+
+There is no cascade, in either direction. Deleting a member repository does not modify the project; the project keeps a coordinate that no longer resolves, and clients render it as unavailable.
+
+## Relay Processing Algorithm
+
+A relay accepting `kind:30621` MUST validate the envelope at ingest. The rule names below are the identifiers the shared fixtures use.
+
+1. **`d-cardinality`** — exactly one `d` tag. Zero or several is rejected. Under NIP-33 a missing `d` is treated as empty, which collapses every such event into the `(pubkey, 30621, "")` slot where unrelated projects silently overwrite each other; several `d` tags make the address reader-dependent.
+2. **`d-empty`** — the `d` value is non-empty. Same collapse hazard. Its length is bounded by the relay's existing generic `d`-tag limit (`buzz_db::event::D_TAG_MAX_LEN`, 1024 bytes); this NIP adds no second bound.
+3. **`member-cap`** — at most 64 member `a` tags, counting **every** `a` tag rather than distinct coordinates. Counting distinct coordinates would leave parse volume bounded only by the relay frame limit (512 KiB by default, `crates/buzz-relay/src/config.rs`), since a duplicate-heavy event could carry thousands of tags naming one coordinate. The cap is inclusive: 64 is accepted, 65 is not.
+4. **`member-coordinate-malformed`** — every member `a` tag parses per [Member coordinates](#member-coordinates).
+5. **`member-duplicate`** — no two member `a` tags hold the same coordinate, compared as exact strings on the canonical form.
+6. **`metadata-cardinality`** — at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility`. Duplicates would make the effective value reader-dependent.
+7. **`metadata-length`** — `name` at most 256 bytes; `description` at most 2048 bytes.
+
+Rules 3 through 5 are evaluated in that order, so an oversized tag list is refused on count before any set proportional to it is built.
+
+**Duplicates are rejected, never normalized.** A relay cannot dedupe tags inside a signed event: rewriting the tag array changes the event id and invalidates the signature. The choices are reject, or accept and require every present and future consumer to apply a first-wins interpretation rule. Rejecting keeps every stored head canonical and spares all consumers a defensive parse.
+
+**No membership authorization.** The relay MUST NOT check whether the signer owns, maintains, or has any relationship to a member repository. Referencing another owner's repository is legal and is the point of the kind. Because membership grants nothing ([Authority](#authority)), there is nothing to authorize.
+
+**Routing.** `kind:30621` is global-only, like every other NIP-34 kind in Buzz: it is addressed by `(pubkey, kind, d)` and is never channel-scoped. A stray `h` tag MUST NOT scope it to a channel — the `buzz-channel` tag is a metadata reference, not a routing directive.
+
+**Scope.** Writes require the `repos:write` scope, matching `kind:30617` and `kind:30618`. A project is repository metadata; a client authorized to announce repositories is authorized to group them.
+
+**Replacement and deletion** follow NIP-33 and NIP-09 with no special cases: newest `created_at` wins per `(pubkey, 30621, d)`, and a `kind:5` from the same pubkey naming the coordinate deletes it.
+
+## Client Behavior
+
+### Listing eligibility
+
+A project is **listing eligible** for a client when that client is currently rendering it in its project collection. A project is not listing eligible when:
+
+- its `buzz-visibility` is `unlisted`, or
+- the viewer has hidden it locally, or
+- it has been deleted, or its latest head is otherwise not being rendered.
+
+Only listing-eligible projects claim members. This keeps visibility deterministic in the case that otherwise breaks: an unlisted project must not make a repository the viewer can plainly see disappear from the collection, because the container that claims it is not on screen to hold it.
+
+### Claim authority
+
+A project **claims** a member — suppressing that repository's implicit card, per step 3 of the fold — only when the project is listing eligible *and* its signer is authorized by the member repository itself: the signer is the repository's owner (the pubkey in the member coordinate), or is listed in a `maintainers` tag on the repository's own live `kind:30617`.
+
+Authority is therefore read from the member repository's *content*, not merely its existence: a client that has resolved only a coordinate, and not the head it names, cannot yet decide whether a project claims it. `maintainers` is the standard NIP-34 multi-value tag; Buzz's own announcement builder does not emit it today, so in practice every current claim reduces to signer-is-owner, and the `maintainers` clause is what keeps a co-maintained repository working the day that changes.
+
+Without this rule, membership would carry exactly the authority [Authority](#authority) says it does not. Anyone may publish a project naming anyone's repository, so an unauthorized project that suppressed implicit cards would let a stranger pull someone else's repository out of the collection and into a container the owner never consented to — a signed assertion silently becoming control over another owner's discovery surface.
+
+An unauthorized project still renders, and still renders its members inside itself: cross-owner grouping works, which is the entire point of the kind. What it cannot do is *remove* a repository from where its owner expects to find it. The visible consequence is that a repository in a stranger's project renders in both places — inside that project and as its own card — which is the correct reading of an unendorsed grouping claim.
+
+### The fold
+
+Given the set of repositories and projects to render, a client MUST derive the collection as follows.
+
+1. **Enumerate exhaustively.** Retrieve the latest live head of every `kind:30621` and `kind:30617` coordinate, plus the `kind:5` deletions bearing on them, using paginated queries that run to exhaustion. A fixed `limit` MUST NOT be used: with a limit of 200, repository 201 vanishes from the collection, which is precisely the compatibility guarantee this NIP owes existing repositories. Pagination MUST tolerate several events sharing one `created_at` — a timestamp-only cursor silently skips same-second events, so the cursor MUST break ties on event id.
+2. **Resolve members.** For each project, resolve each member coordinate to its repository head, and determine whether the project [claims](#claim-authority) each one.
+3. **Suppress claimed implicit cards.** A live repository claimed by at least one project does not also render as an implicit single-repository card.
+4. **Render multiple membership.** A repository belonging to several listing-eligible projects renders inside each of them, claimed or not.
+5. **Fall back.** A repository claimed by no project renders as an implicit single-repository card — including when an unauthorized project also renders it as a member.
+6. **Mark unresolvable members.** A member coordinate that resolves to nothing — never announced, deleted, or not present on this relay — renders inside its project as explicitly unavailable. It MUST NOT become a phantom standalone card, and it MUST NOT be silently dropped: silence makes a project look smaller than its author declared.
+7. **Hiding a container never hides repositories.** Locally hiding a project makes it not listing eligible, so it claims nothing and by step 5 its members return as implicit cards. Hiding a grouping is a statement about the grouping. A repository disappears from the collection only when the viewer hides that repository or it is deleted — and a repository the viewer has hidden is hidden everywhere, including inside every project that lists it, so hiding one cannot be undone by someone else's grouping.
+
+The fold is deterministic: same heads in, same collection out, independent of arrival order or query shape. Every live, unhidden repository renders in at least one place — inside a project that claims it, or as its own card — and no repository renders twice within one container.
+
+### Required fold cases
+
+The fold cannot be expressed as accept/reject of a single event, so it is not in the [conformance fixtures](#conformance-fixtures) — those are the ingest contract. A client implementing the fold MUST cover at least these cases, each of which is a distinct branch above:
+
+| Case | Expected collection |
+|------|---------------------|
+| Owner's own project lists their repository | Repository renders inside the project only |
+| Stranger's project lists someone else's repository | Repository renders inside that project *and* as its own card |
+| Project signer is in the member repository's `maintainers` tag | Repository renders inside the project only |
+| Repository is a member of two projects that both claim it | Repository renders inside both; no implicit card |
+| Repository removed from every project | Repository renders as an implicit card |
+| Project is `unlisted`, or locally hidden | Project absent from the collection; its members render as implicit cards |
+| Viewer has hidden a member repository | Repository absent from the collection *and* from inside every project listing it |
+| Member coordinate resolves to nothing | Member renders inside its project as unavailable; no standalone card |
+| Project head deleted | Project absent; its members render as implicit cards |
+| More repositories and projects than one page holds, with several sharing one `created_at` | Every repository and project renders |
+
+### Collection growth
+
+Step 1's exhaustive enumeration is a correctness floor, not a scaling strategy: it says a client MUST NOT silently truncate its collection, because a repository absent from the list is indistinguishable from one that does not exist. It is not a mandate to hold the relay's entire repository set in memory on every load.
+
+At Buzz's current scale (hundreds of repositories per community) exhaustive enumeration is the whole story. Past that, the way out is a narrower question — a server-side collection query, a scoped or searched subset, or resolving a project's members on demand — not a fixed client-side `limit`. Any such surface MUST report its own truncation so a client can say "showing N of M" rather than quietly presenting a partial collection as complete.
+
+### Route resolution
+
+A project route resolves to a container; a repository route resolves to a repository. Every repository-scoped operation — clone, fetch, issues, pull requests, activity, mutation, deletion — MUST take an explicit repository coordinate. None may infer its target from container state, or a two-repository project will silently operate on the wrong member.
+
+Legacy `<owner>:<dtag>` repository routes remain valid and resolve to that repository, presented as a single-repository container.
+
+## Conformance Fixtures
+
+[`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) holds the shared valid/invalid case set: 9 accepted and 16 rejected events covering minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, and each rejection rule above.
+
+The relay validator, the Rust builder, and the TypeScript builder all test against this one file, so a divergence between them is a test failure rather than a production surprise.
+
+Each case carries an **unsigned** template — `kind`, `content`, `tags`. Consumers sign it with their own test key. Signed literals would be inert: the id and signature are fixed by the exact serialization, so any consumer that re-serializes would need to recompute both anyway. Rejection cases name their `reject_rules`, so an implementation cannot pass by rejecting a bad event for an unrelated reason.
+
+## Security Considerations
+
+**Unauthorized grouping claims are the accepted trade.** Anyone may publish a project referencing anyone's repositories. That claim is a signed statement attributable to its author and grants nothing ([Authority](#authority)) — the same trust model as NIP-51 lists, which likewise reference content their author does not own. A client MUST NOT present membership in a stranger's project as endorsement by, or authority over, the member repository's owner, and MUST show the project signer alongside a project it did not author.
+
+**Resolution fan-out is bounded.** Each project resolves at most 64 coordinates, and the cap counts raw tags, so no single event can force unbounded resolution work regardless of how its tag list is shaped.
+
+**Push policy is untouched.** A project cannot grant, widen, or narrow push access to any repository. Push policy reads only the repository's own `kind:30617`. This is a design invariant, not an implementation detail: if a project ever became an input to push authorization, publishing a project naming someone else's repository would become a privilege-escalation primitive.
+
+## Relation to Other NIPs
+
+- **NIP-34**: Supplies the member repositories. Members are `kind:30617` announcements referenced by coordinate; a NIP-34 client that does not know `kind:30621` still discovers and renders each repository normally.
+- **NIP-33**: Supplies addressing, replacement, and the owner-only editing model. Owner-only editing is not enforcement code in Buzz — it is what NIP-33 replacement already means.
+- **NIP-09**: Supplies container deletion, which deletes the container only.
+- **NIP-29**: Supplies the channel a project's `buzz-channel` names. The reference is metadata; project state is never channel-scoped.
+- **NIP-51**: The closest existing precedent — a signed, addressable list referencing content the author need not own. Not reused because a project is a shared named forge container with its own channel binding and visibility, not a user's private-or-public bookmark set.
+- **NIP-OA**: Unaffected. Agents inherit repository push access from their owner through the repository's own protections; a project is never consulted.

From 998d6b7557fc432630ba3091b1b3fdf82b758a4b Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Mon, 27 Jul 2026 16:10:07 -0400
Subject: [PATCH 02/10] docs(nips): correct NIP-MP deletion authority to match
 relay policy

The spec claimed deletion is signer-only, but the Buzz relay's
validate_standard_deletion_event also accepts the target pubkey's
registered NIP-OA owner, so a human can clean up events published by an
agent they own. That is deliberate platform-wide policy, not a project
carve-out, so the spec documents it as a Buzz extension to NIP-09 rather
than special-casing kind:30621.

Replacement stays signer-only on every relay: NIP-33 keys the coordinate
on the pubkey rather than on a permission check. Also records NIP-09's
at-or-before created_at scoping for a-tag deletions, which the relay now
enforces at the coordinate.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.md | 19 +++++++++++++++----
 1 file changed, 15 insertions(+), 4 deletions(-)

diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index ccaf7fb3d1..99c12d98db 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -119,7 +119,7 @@ A project MAY reference a coordinate that resolves to nothing: a repository not
 
 The project signer's authority begins and ends at the container.
 
-- **Over the container**: total. Only the signer can replace or delete their `(pubkey, 30621, d)` coordinate.
+- **Over the container**: total. Only the signer can replace their `(pubkey, 30621, d)` coordinate. Deletion additionally admits the signer's registered NIP-OA owner — see [Deletion](#deletion).
 - **Over member repositories**: none. No edit, no delete, no push, no administration, no ability to change a member repository's own metadata or protections. Adding Bob's repository to Alice's project changes nothing about Bob's repository or who may push to it. It is Alice's signed assertion that the two belong together, and it is attributable to her key.
 
 Clients MUST preserve each member repository's own owner provenance in the UI. A repository rendered inside a project must not appear to be owned or governed by the project signer.
@@ -146,6 +146,12 @@ A repository may be a member of any number of projects. It renders inside each (
 
 Deleting a project (NIP-09 `kind:5` naming the project coordinate) deletes the `kind:30621` only. Member repositories are untouched — their `kind:30617` events, refs, channels, and protections all survive, and each falls back to an implicit card unless another listing-eligible project claims it.
 
+**Who may delete.** The project signer always may. On the Buzz relay, so may the signer's registered NIP-OA owner: `validate_standard_deletion_event` resolves the deletion's effective author and accepts it when that actor is the target pubkey's registered owner (`crates/buzz-relay/src/handlers/side_effects.rs`). This is a **Buzz relay extension to NIP-09**, applied uniformly to every kind rather than specially to projects — it is what lets a human clean up events published by an agent they own. Vanilla NIP-09 relays accept only the signer, so a project deleted through the owner path on Buzz will still be live on a relay that lacks the extension.
+
+Replacement admits no such widening: it is signer-only on every relay, because NIP-33 keys the coordinate on the pubkey itself rather than on a permission check.
+
+A deletion whose `created_at` precedes the live head does not remove it — see [Relay Processing Algorithm](#relay-processing-algorithm).
+
 There is no cascade, in either direction. Deleting a member repository does not modify the project; the project keeps a coordinate that no longer resolves, and clients render it as unavailable.
 
 ## Relay Processing Algorithm
@@ -170,7 +176,12 @@ Rules 3 through 5 are evaluated in that order, so an oversized tag list is refus
 
 **Scope.** Writes require the `repos:write` scope, matching `kind:30617` and `kind:30618`. A project is repository metadata; a client authorized to announce repositories is authorized to group them.
 
-**Replacement and deletion** follow NIP-33 and NIP-09 with no special cases: newest `created_at` wins per `(pubkey, 30621, d)`, and a `kind:5` from the same pubkey naming the coordinate deletes it.
+**Replacement** follows NIP-33 with no special cases: newest `created_at` wins per `(pubkey, 30621, d)`, and one pubkey can never overwrite another's coordinate.
+
+**Deletion** follows NIP-09 with two Buzz-wide behaviors that are not project-specific:
+
+- A `kind:5` naming the coordinate deletes it when signed by the project signer **or** by that signer's registered NIP-OA owner ([Deletion](#deletion)).
+- The deletion applies only to versions whose `created_at` is at or before the deletion's own, per NIP-09. A delayed or replayed tombstone signed before the current head MUST NOT remove it; the relay compares timestamps at the coordinate (`soft_delete_by_coordinate`, `crates/buzz-db/src/event.rs`).
 
 ## Client Behavior
 
@@ -257,7 +268,7 @@ Each case carries an **unsigned** template — `kind`, `content`, `tags`. Consum
 
 - **NIP-34**: Supplies the member repositories. Members are `kind:30617` announcements referenced by coordinate; a NIP-34 client that does not know `kind:30621` still discovers and renders each repository normally.
 - **NIP-33**: Supplies addressing, replacement, and the owner-only editing model. Owner-only editing is not enforcement code in Buzz — it is what NIP-33 replacement already means.
-- **NIP-09**: Supplies container deletion, which deletes the container only.
+- **NIP-09**: Supplies container deletion, which deletes the container only. Buzz extends it in two ways that are not project-specific: an agent's registered NIP-OA owner may also delete, and a tombstone applies only at or before its own `created_at` ([Deletion](#deletion)).
 - **NIP-29**: Supplies the channel a project's `buzz-channel` names. The reference is metadata; project state is never channel-scoped.
 - **NIP-51**: The closest existing precedent — a signed, addressable list referencing content the author need not own. Not reused because a project is a shared named forge container with its own channel binding and visibility, not a user's private-or-public bookmark set.
-- **NIP-OA**: Unaffected. Agents inherit repository push access from their owner through the repository's own protections; a project is never consulted.
+- **NIP-OA**: Consulted for container deletion only — an agent's registered owner may delete the agent's project ([Deletion](#deletion)). Push access is unaffected: agents inherit repository push access from their owner through the repository's own protections, and a project is never consulted.

From d31c8db2db9915b888c06a633435eea5f37921ab Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Tue, 28 Jul 2026 14:09:29 -0400
Subject: [PATCH 03/10] docs(nips): close NIP-MP ambiguities and mechanize the
 fold

Second-implementer review surfaced places where NIP-MP left a conformant
client free to diverge, and one gap where its strongest security property
was prose-only.

Metadata fallbacks, the NIP-01 relay-hint element, and content handling
were underspecified: two clients could disagree on an unrecognized
visibility token, an unresolvable channel, or a non-empty content and
both claim conformance. Each is now normative, and buzz-channel and
buzz-visibility gain the length bound the other metadata tags already
had.

Pagination was single-mode and unsatisfiable off Buzz: NIP-01 filters
have no event-id tiebreak, so exhaustive enumeration cannot be
guaranteed there. It now specifies the composite-cursor mode Buzz
provides and requires vanilla-relay clients to detect and report
possible truncation rather than present a partial collection as
complete.

The fold carries the claim-authority rule that stops a stranger's
project from pulling a repository out of its owner's collection, but had
only a prose table, so two clients could pass and still render
differently. NIP-MP.fold-fixtures.json makes it machine-checkable with
semantic inputs rather than signed envelopes, since the oracle tests
placement and not event validation.

Citations are corrected to match: NIP-01 rather than the NIP-33
tombstone upstream redirects away, the coordinate-tombstone timestamp
bound attributed to the change that introduces it, and the fixture
consumer claim stated as a requirement rather than as existing coverage.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.fixtures.json      | 111 +++++-
 docs/nips/NIP-MP.fold-fixtures.json | 527 ++++++++++++++++++++++++++++
 docs/nips/NIP-MP.md                 |  81 +++--
 3 files changed, 695 insertions(+), 24 deletions(-)
 create mode 100644 docs/nips/NIP-MP.fold-fixtures.json

diff --git a/docs/nips/NIP-MP.fixtures.json b/docs/nips/NIP-MP.fixtures.json
index 89b7fa4166..dedd161f1f 100644
--- a/docs/nips/NIP-MP.fixtures.json
+++ b/docs/nips/NIP-MP.fixtures.json
@@ -1,5 +1,5 @@
 {
-  "$comment": "NIP-MP conformance fixtures \u2014 the shared contract for the relay ingest validator, the Rust event builder, and the TypeScript event builder. Each case carries an UNSIGNED template: consumers sign it with their own test key, because signing fixes the event id and signature and a stored literal would not survive re-serialization. `expect` is the ingest outcome. `reject_rules` lists the validation rules that may fire; an implementation must reject for one of them, so it cannot pass by rejecting for an unrelated reason. Spec: docs/nips/NIP-MP.md.",
+  "$comment": "NIP-MP conformance fixtures \u2014 the shared ingest contract for the relay validator, the Rust event builder, and the TypeScript event builder. Each case carries an UNSIGNED template: consumers sign it with their own test key, because signing fixes the event id and signature and a stored literal would not survive re-serialization. `expect` is the ingest outcome. `reject_rules` lists the validation rules that may fire; an implementation must reject for one of them, so it cannot pass by rejecting for an unrelated reason. This file covers single-event accept/reject only; the client-side fold has its own oracle in NIP-MP.fold-fixtures.json. Spec: docs/nips/NIP-MP.md.",
   "version": 1,
   "kind": 30621,
   "member_cap": 64,
@@ -420,7 +420,7 @@
     {
       "name": "valid_uninterpreted_metadata_values",
       "expect": "accept",
-      "note": "`buzz-channel` is not a UUID and `buzz-visibility` is an unrecognized token. Ingest bounds metadata cardinality and length but does not interpret these values \u2014 matching how kind:30617 carries the same two tags. A client that cannot resolve the channel renders the project without one; an unrecognized visibility is treated as `listed`.",
+      "note": "`buzz-channel` is not a UUID and `buzz-visibility` is an unrecognized token. Ingest bounds metadata cardinality and length but does not interpret these values \u2014 matching how kind:30617 carries the same two tags. Client-side fallbacks for both are normative in the spec's Metadata interpretation section: an unresolvable channel renders the project without one, and an unrecognized visibility is treated as `listed`.",
       "template": {
         "kind": 30621,
         "content": "",
@@ -459,6 +459,41 @@
         ]
       }
     },
+    {
+      "name": "valid_member_relay_hint",
+      "expect": "accept",
+      "note": "Member `a` tag carries NIP-01's optional third element, a relay hint. Ingest validates the coordinate in element 1 only, so the hint is neither validated nor a rejection cause; a client MAY use it to resolve an otherwise unavailable member.",
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+            "wss://relay.example.com"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "valid_non_empty_content",
+      "expect": "accept",
+      "note": "`content` holds a value. Writers should emit the empty string, but readers and relays must ignore whatever is there \u2014 a non-empty `content` is not a rejection cause and carries no semantics.",
+      "template": {
+        "kind": 30621,
+        "content": "ignored by every consumer",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ]
+        ]
+      }
+    },
     {
       "name": "invalid_d_missing",
       "expect": "reject",
@@ -543,6 +578,34 @@
         ]
       }
     },
+    {
+      "name": "invalid_member_duplicate_differing_relay_hints",
+      "expect": "reject",
+      "note": "The same coordinate twice under different relay hints. Duplicate detection compares the coordinate alone, so differing hints do not make these distinct members; a validator that compares whole tag arrays would wrongly accept this.",
+      "reject_rules": [
+        "member-duplicate"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+            "wss://relay-one.example.com"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+            "wss://relay-two.example.com"
+          ]
+        ]
+      }
+    },
     {
       "name": "invalid_member_cap_exceeded",
       "expect": "reject",
@@ -1326,6 +1389,50 @@
           ]
         ]
       }
+    },
+    {
+      "name": "invalid_metadata_channel_too_long",
+      "expect": "reject",
+      "note": "`buzz-channel` value exceeds 256 bytes. Paired with `invalid_metadata_visibility_too_long` so each bound is exercised by an event whose other metadata is valid \u2014 neither check can hide behind the other's rejection.",
+      "reject_rules": [
+        "metadata-length"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "buzz-channel",
+            "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+          ]
+        ]
+      }
+    },
+    {
+      "name": "invalid_metadata_visibility_too_long",
+      "expect": "reject",
+      "note": "`buzz-visibility` value exceeds 256 bytes. Ingest does not interpret the token, but it must still bound its length.",
+      "reject_rules": [
+        "metadata-length"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "buzz-visibility",
+            "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv"
+          ]
+        ]
+      }
     }
   ]
 }
diff --git a/docs/nips/NIP-MP.fold-fixtures.json b/docs/nips/NIP-MP.fold-fixtures.json
new file mode 100644
index 0000000000..3eb1b0a045
--- /dev/null
+++ b/docs/nips/NIP-MP.fold-fixtures.json
@@ -0,0 +1,527 @@
+{
+  "$comment": "NIP-MP fold fixtures \u2014 the shared oracle for the client-side fold in docs/nips/NIP-MP.md (\"The fold\" and \"Required fold cases\"). Every case in the spec's required-cases table appears here, keyed by `spec_case`. Inputs are SEMANTIC, not signed envelopes: a repository or project is named by its coordinate plus the fold's inputs (signer, members, `maintainers`, visibility, viewer-hidden, deletion). Signing and envelope validation are the ingest contract and live in NIP-MP.fixtures.json; this file assumes every input is a valid, accepted head and tests only the placement the fold derives from it. `expect.containers` lists each rendered project with the members rendered inside it; `expect.implicit_cards` lists the repositories that additionally render as their own single-repository cards. Order is not significant in either list \u2014 compare as sets. `render` is `resolved` when the member coordinate resolves to a live head and `unavailable` when it does not. `state` is `live` or `deleted`; `visibility` is `listed` or `unlisted`; a value of `viewer_hidden` is a local, per-viewer decision, not event state.",
+  "version": 1,
+  "project_kind": 30621,
+  "repository_kind": 30617,
+  "pubkeys": {
+    "alice": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "bob": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+    "carol": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+  },
+  "cases": [
+    {
+      "name": "owner_project_claims_own_repository",
+      "spec_case": "Owner's own project lists their repository",
+      "note": "The signer is the member coordinate's owner, so the project claims the repository and step 3 suppresses its implicit card.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": []
+      }
+    },
+    {
+      "name": "stranger_project_does_not_claim",
+      "spec_case": "Stranger's project lists someone else's repository",
+      "note": "Bob is neither the owner nor a maintainer, so his project renders the member but claims nothing. The repository keeps its own card: an unendorsed grouping cannot pull a repository out of where its owner expects to find it.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed",
+          "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": [
+          "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+        ]
+      }
+    },
+    {
+      "name": "maintainer_signer_claims",
+      "spec_case": "Project signer is in the member repository's `maintainers` tag",
+      "note": "Claim authority is read from the member repository's own head, not from ownership alone. Carol is listed in `maintainers`, so her project claims the repository exactly as the owner's would.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false,
+          "maintainers": [
+            "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+          ]
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:platform",
+          "signer": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:platform",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": []
+      }
+    },
+    {
+      "name": "two_claiming_projects_both_render_member",
+      "spec_case": "Repository is a member of two projects that both claim it",
+      "note": "Multiple membership is not a move. The repository renders inside both containers and has no implicit card, and it appears once per container rather than twice in either.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        },
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:infra",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              }
+            ]
+          },
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:infra",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": []
+      }
+    },
+    {
+      "name": "repository_removed_from_every_project",
+      "spec_case": "Repository removed from every project",
+      "note": "A live project that no longer lists the repository. The container renders empty rather than being hidden, and the unclaimed repository falls back to its own card.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+            "members": []
+          }
+        ],
+        "implicit_cards": [
+          "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+        ]
+      }
+    },
+    {
+      "name": "unlisted_project_absent_member_falls_back",
+      "spec_case": "Project is `unlisted`, or locally hidden",
+      "note": "An unlisted project is not listing eligible, so it claims nothing even though its signer owns the member. The container that would hold the repository is not on screen, so the repository must render as its own card or vanish.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:skunkworks",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "unlisted",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [],
+        "implicit_cards": [
+          "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+        ]
+      }
+    },
+    {
+      "name": "locally_hidden_project_absent_member_falls_back",
+      "spec_case": "Project is `unlisted`, or locally hidden",
+      "note": "The second half of the same rule, reached by a different input: hiding a container is a statement about the grouping only. The viewer-hidden project claims nothing and its member returns as an implicit card.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": true
+        }
+      ],
+      "expect": {
+        "containers": [],
+        "implicit_cards": [
+          "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+        ]
+      }
+    },
+    {
+      "name": "viewer_hidden_repository_absent_everywhere",
+      "spec_case": "Viewer has hidden a member repository",
+      "note": "Hiding a repository hides it everywhere, including inside every project that lists it \u2014 otherwise someone else's grouping could undo the viewer's decision. The sibling member is unaffected.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": true
+        },
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra",
+                "render": "resolved"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": []
+      }
+    },
+    {
+      "name": "unresolvable_member_renders_unavailable",
+      "spec_case": "Member coordinate resolves to nothing",
+      "note": "No repository answers the coordinate \u2014 never announced, deleted, or absent from this relay. It renders inside its project as explicitly unavailable: dropping it silently would make the project look smaller than its author declared, and promoting it to a standalone card would invent a repository.",
+      "repositories": [],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:ghost"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:ghost",
+                "render": "unavailable"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": []
+      }
+    },
+    {
+      "name": "deleted_project_absent_member_falls_back",
+      "spec_case": "Project head deleted",
+      "note": "Deletion does not cascade. The container is gone; the member repository, its refs and its channel survive and it falls back to an implicit card.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "deleted",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [],
+        "implicit_cards": [
+          "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+        ]
+      }
+    },
+    {
+      "name": "authorized_and_unauthorized_projects_share_member",
+      "spec_case": "One authorized and one unauthorized project both list the same repository",
+      "note": "The discriminating case for step 3's \"at least one\": Alice's claim suppresses the implicit card, and Bob's unauthorized project still renders the member. An implementation that requires every listing project to be authorized would wrongly emit an implicit card here; one that lets any listing project suppress would wrongly emit none in `stranger_project_does_not_claim`.",
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        },
+        {
+          "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed",
+          "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              }
+            ]
+          },
+          {
+            "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": []
+      }
+    },
+    {
+      "name": "exhaustive_enumeration_across_pages_with_tied_timestamps",
+      "spec_case": "More repositories and projects than one page holds, with several sharing one `created_at`",
+      "note": "Exercises step 1 rather than the placement rules: the inputs exceed `enumeration.page_size` and three entities share one `created_at`, so a timestamp-only cursor loses events at the page boundary. Every repository and project must still render. `created_at` is present only on this case, and only because the cursor is what is under test.",
+      "enumeration": {
+        "page_size": 2,
+        "cursor": "composite",
+        "note": "The harness must serve inputs in pages of `page_size`, ordered `(created_at DESC, coordinate ASC)`, and the client under test must page with a composite `(created_at, id)` cursor per the Pagination section. Ordering by coordinate stands in for event id, which unsigned semantic fixtures do not carry."
+      },
+      "repositories": [
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+          "state": "live",
+          "viewer_hidden": false,
+          "created_at": 1000
+        },
+        {
+          "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra",
+          "state": "live",
+          "viewer_hidden": false,
+          "created_at": 1000
+        },
+        {
+          "coordinate": "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile",
+          "state": "live",
+          "viewer_hidden": false,
+          "created_at": 1000
+        }
+      ],
+      "projects": [
+        {
+          "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+          "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+          "members": [
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false,
+          "created_at": 900
+        },
+        {
+          "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:mobile",
+          "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+          "members": [
+            "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile"
+          ],
+          "visibility": "listed",
+          "state": "live",
+          "viewer_hidden": false,
+          "created_at": 900
+        }
+      ],
+      "expect": {
+        "containers": [
+          {
+            "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform",
+            "members": [
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+                "render": "resolved"
+              },
+              {
+                "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra",
+                "render": "resolved"
+              }
+            ]
+          },
+          {
+            "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:mobile",
+            "members": [
+              {
+                "coordinate": "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile",
+                "render": "resolved"
+              }
+            ]
+          }
+        ],
+        "implicit_cards": []
+      }
+    }
+  ]
+}
diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index 99c12d98db..7d92d17313 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -6,7 +6,7 @@ Multi-Repository Projects
 
 `draft` `optional` `relay`
 
-**Depends on**: NIP-01 (basic event format), NIP-33 (parameterized replaceable events), NIP-34 (git repositories), NIP-09 (event deletion). Interacts with NIP-29 (the channel a project links to) and NIP-OA (owner attestation, for how agents inherit repo push access).
+**Depends on**: NIP-01 (basic event format, addressable events), NIP-34 (git repositories), NIP-09 (event deletion). Interacts with NIP-29 (the channel a project links to) and NIP-OA (owner attestation, for how agents inherit repo push access).
 
 ## Abstract
 
@@ -40,7 +40,7 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d
 
 - **project**: A `kind:30621` event. Also called the *container*.
 - **member**: A repository referenced by a project, named by an `a` tag holding a repository coordinate.
-- **coordinate**: The NIP-33 address of a repository announcement, `30617:<owner-pubkey-hex>:<repo-d-tag>`.
+- **coordinate**: The NIP-01 address of a repository announcement, `30617:<owner-pubkey-hex>:<repo-d-tag>`.
 - **explicit project**: A project that exists as a `kind:30621` event.
 - **implicit project**: The single-repository card a client renders for a `kind:30617` that no listing-eligible explicit project claims. Not an event — a rendering fallback.
 - **listing eligible**: A project a client is currently rendering in its project collection. See [Listing eligibility](#listing-eligibility).
@@ -49,9 +49,9 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d
 
 | Kind | Name | Signer | Class | Purpose |
 |------|------|--------|-------|---------|
-| `30621` | Project | user | parameterized replaceable | A named grouping of `kind:30617` repository announcements |
+| `30621` | Project | user | addressable | A named grouping of `kind:30617` repository announcements |
 
-`kind:30621` is parameterized replaceable per NIP-33 (`30000 <= n < 40000`), addressed by `(pubkey, 30621, d)`. Two signers may use the same `d` value; those are two distinct projects.
+`kind:30621` is an addressable event per NIP-01 (`30000 <= n < 40000`), addressed by `(pubkey, 30621, d)`. Two signers may use the same `d` value; those are two distinct projects. Addressable events were formerly specified as "parameterized replaceable events" in NIP-33, which upstream has since folded into NIP-01; this document cites NIP-01 throughout.
 
 ### Kind allocation
 
@@ -86,20 +86,32 @@ Both external registries are advisory, not authoritative allocators: neither res
 
 | Tag | Cardinality | Meaning |
 |-----|-------------|---------|
-| `d` | exactly 1, non-empty | Project slug. The NIP-33 identifier. |
+| `d` | exactly 1, non-empty | Project slug. The NIP-01 addressable identifier. |
 | `name` | 0 or 1 | Human-readable display name. Clients fall back to `d` when absent. |
 | `description` | 0 or 1 | Free text describing the project. |
 | `a` | 0 to 64 | One member repository coordinate each. Order is not significant. |
-| `buzz-channel` | 0 or 1 | UUID of the channel this project's discussion lives in. Metadata only — see [Authority](#authority). |
-| `buzz-visibility` | 0 or 1 | `listed` (default) or `unlisted`. Feeds [listing eligibility](#listing-eligibility). |
+| `buzz-channel` | 0 or 1 | UUID of the channel this project's discussion lives in. Metadata only — see [Authority](#authority). At most 256 bytes. |
+| `buzz-visibility` | 0 or 1 | `listed` (default) or `unlisted`. Feeds [listing eligibility](#listing-eligibility). At most 256 bytes. |
 
-`content` is empty and carries no meaning. Clients and relays MUST NOT parse semantics from it.
+`content` carries no meaning. Writers SHOULD emit the empty string. Readers and relays MUST ignore whatever it holds: a non-empty `content` is not a rejection cause, and no consumer may parse semantics from it. Reserving it costs nothing and keeps a future writer that fills it from invalidating its events for today's readers.
 
 Unrecognized tags MUST be ignored rather than rejected, so a newer writer can add metadata without invalidating its events for older readers.
 
+### Metadata interpretation
+
+Ingest bounds metadata cardinality and length; it interprets no metadata value. `buzz-channel` and `buzz-visibility` are opaque strings to a relay, exactly as they are on `kind:30617`. Interpretation is a client concern, and every client MUST resolve it the same way:
+
+- `name` absent → clients display the `d` value.
+- `buzz-visibility` absent or holding any value other than `listed` or `unlisted` → treated as `listed`. An unrecognized token MUST NOT hide a project: a typo in a metadata field is not a privacy signal, and treating it as one would make a project vanish for reasons its author cannot see.
+- `buzz-channel` absent, or naming a channel the viewer cannot resolve or read → the project renders without a channel link. It MUST NOT be dropped from the collection, and the unresolvable value MUST NOT be surfaced as a broken link.
+
 ### Member coordinates
 
-A member `a` tag value MUST be exactly `30617:<owner>:<repo-d>` where:
+A member `a` tag follows NIP-01's `a` tag grammar: `["a", "<coordinate>"]` or `["a", "<coordinate>", "<relay-url>"]`. Ingest validates the coordinate — element 1 — and nothing else.
+
+The optional third element is a **relay hint**: a recommended relay where the member announcement may be found. Clients MAY use it when resolving a member that step 6 of [the fold](#the-fold) would otherwise mark unavailable, and MUST treat it as advice rather than authority — a hint is unauthenticated, supplied by the project signer rather than the repository owner, so a resolution through it MUST still verify that the retrieved event is the coordinate's own signed `kind:30617`. A hint MUST NOT be required: a project whose members are all on the reading relay resolves fully without one, and a client that ignores hints entirely is conformant.
+
+A member `a` tag coordinate MUST be exactly `30617:<owner>:<repo-d>` where:
 
 - the kind segment is the literal `30617`. A project groups repository *announcements*; a coordinate naming any other kind (notably `30618` repository state) is malformed.
 - `<owner>` is 64 lowercase hex characters. Uppercase is rejected: `#a` filter matching is byte-exact, so an uppercase-owner head would be invisible to the lowercase-coordinate queries every reader issues.
@@ -128,7 +140,7 @@ Clients MUST preserve each member repository's own owner provenance in the UI. A
 
 ### Editing model
 
-Editing is **owner-only**: publish a replacement `kind:30621` with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-33 replacement already refuses to let one pubkey overwrite another's coordinate.
+Editing is **owner-only**: publish a replacement `kind:30621` with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-01 replacement already refuses to let one pubkey overwrite another's coordinate.
 
 Delegated or maintainer editing is deliberately out of scope for this version. Adding it later needs no change to this event shape — only a new rule about who may replace a coordinate.
 
@@ -148,7 +160,7 @@ Deleting a project (NIP-09 `kind:5` naming the project coordinate) deletes the `
 
 **Who may delete.** The project signer always may. On the Buzz relay, so may the signer's registered NIP-OA owner: `validate_standard_deletion_event` resolves the deletion's effective author and accepts it when that actor is the target pubkey's registered owner (`crates/buzz-relay/src/handlers/side_effects.rs`). This is a **Buzz relay extension to NIP-09**, applied uniformly to every kind rather than specially to projects — it is what lets a human clean up events published by an agent they own. Vanilla NIP-09 relays accept only the signer, so a project deleted through the owner path on Buzz will still be live on a relay that lacks the extension.
 
-Replacement admits no such widening: it is signer-only on every relay, because NIP-33 keys the coordinate on the pubkey itself rather than on a permission check.
+Replacement admits no such widening: it is signer-only on every relay, because NIP-01 keys the coordinate on the pubkey itself rather than on a permission check.
 
 A deletion whose `created_at` precedes the live head does not remove it — see [Relay Processing Algorithm](#relay-processing-algorithm).
 
@@ -158,16 +170,18 @@ There is no cascade, in either direction. Deleting a member repository does not
 
 A relay accepting `kind:30621` MUST validate the envelope at ingest. The rule names below are the identifiers the shared fixtures use.
 
-1. **`d-cardinality`** — exactly one `d` tag. Zero or several is rejected. Under NIP-33 a missing `d` is treated as empty, which collapses every such event into the `(pubkey, 30621, "")` slot where unrelated projects silently overwrite each other; several `d` tags make the address reader-dependent.
+1. **`d-cardinality`** — exactly one `d` tag. Zero or several is rejected. Under NIP-01 a missing `d` is treated as empty, which collapses every such event into the `(pubkey, 30621, "")` slot where unrelated projects silently overwrite each other; several `d` tags make the address reader-dependent.
 2. **`d-empty`** — the `d` value is non-empty. Same collapse hazard. Its length is bounded by the relay's existing generic `d`-tag limit (`buzz_db::event::D_TAG_MAX_LEN`, 1024 bytes); this NIP adds no second bound.
 3. **`member-cap`** — at most 64 member `a` tags, counting **every** `a` tag rather than distinct coordinates. Counting distinct coordinates would leave parse volume bounded only by the relay frame limit (512 KiB by default, `crates/buzz-relay/src/config.rs`), since a duplicate-heavy event could carry thousands of tags naming one coordinate. The cap is inclusive: 64 is accepted, 65 is not.
-4. **`member-coordinate-malformed`** — every member `a` tag parses per [Member coordinates](#member-coordinates).
-5. **`member-duplicate`** — no two member `a` tags hold the same coordinate, compared as exact strings on the canonical form.
+4. **`member-coordinate-malformed`** — every member `a` tag's coordinate (element 1) parses per [Member coordinates](#member-coordinates). A relay hint in element 3 is not validated and MUST NOT be a rejection cause.
+5. **`member-duplicate`** — no two member `a` tags hold the same coordinate, compared as exact strings on the canonical form. Comparison is on the coordinate alone, so two tags naming one coordinate with different relay hints are duplicates.
 6. **`metadata-cardinality`** — at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility`. Duplicates would make the effective value reader-dependent.
-7. **`metadata-length`** — `name` at most 256 bytes; `description` at most 2048 bytes.
+7. **`metadata-length`** — `name` at most 256 bytes; `description` at most 2048 bytes; `buzz-channel` at most 256 bytes; `buzz-visibility` at most 256 bytes. The two `buzz-` bounds are generous by design: neither value has a semantic length, and the bound exists only so an unbounded string cannot ride into storage on a tag ingest does not interpret.
 
 Rules 3 through 5 are evaluated in that order, so an oversized tag list is refused on count before any set proportional to it is built.
 
+The `buzz-channel` and `buzz-visibility` bounds in rule 7 land in the Buzz validator together with the fixture wiring that exercises them; the validator bounds `name` and `description` today.
+
 **Duplicates are rejected, never normalized.** A relay cannot dedupe tags inside a signed event: rewriting the tag array changes the event id and invalidates the signature. The choices are reject, or accept and require every present and future consumer to apply a first-wins interpretation rule. Rejecting keeps every stored head canonical and spares all consumers a defensive parse.
 
 **No membership authorization.** The relay MUST NOT check whether the signer owns, maintains, or has any relationship to a member repository. Referencing another owner's repository is legal and is the point of the kind. Because membership grants nothing ([Authority](#authority)), there is nothing to authorize.
@@ -176,12 +190,12 @@ Rules 3 through 5 are evaluated in that order, so an oversized tag list is refus
 
 **Scope.** Writes require the `repos:write` scope, matching `kind:30617` and `kind:30618`. A project is repository metadata; a client authorized to announce repositories is authorized to group them.
 
-**Replacement** follows NIP-33 with no special cases: newest `created_at` wins per `(pubkey, 30621, d)`, and one pubkey can never overwrite another's coordinate.
+**Replacement** follows NIP-01 with no special cases: newest `created_at` wins per `(pubkey, 30621, d)`, and one pubkey can never overwrite another's coordinate.
 
 **Deletion** follows NIP-09 with two Buzz-wide behaviors that are not project-specific:
 
 - A `kind:5` naming the coordinate deletes it when signed by the project signer **or** by that signer's registered NIP-OA owner ([Deletion](#deletion)).
-- The deletion applies only to versions whose `created_at` is at or before the deletion's own, per NIP-09. A delayed or replayed tombstone signed before the current head MUST NOT remove it; the relay compares timestamps at the coordinate (`soft_delete_by_coordinate`, `crates/buzz-db/src/event.rs`).
+- The deletion applies only to versions whose `created_at` is at or before the deletion's own, per NIP-09. A delayed or replayed tombstone signed before the current head MUST NOT remove it; the relay compares timestamps at the coordinate (`soft_delete_by_coordinate`, `crates/buzz-db/src/event.rs`, whose inclusive `created_at <= <deletion>` bound is introduced alongside this specification in [#3171](https://github.com/block/buzz/pull/3171)).
 
 ## Client Behavior
 
@@ -209,7 +223,7 @@ An unauthorized project still renders, and still renders its members inside itse
 
 Given the set of repositories and projects to render, a client MUST derive the collection as follows.
 
-1. **Enumerate exhaustively.** Retrieve the latest live head of every `kind:30621` and `kind:30617` coordinate, plus the `kind:5` deletions bearing on them, using paginated queries that run to exhaustion. A fixed `limit` MUST NOT be used: with a limit of 200, repository 201 vanishes from the collection, which is precisely the compatibility guarantee this NIP owes existing repositories. Pagination MUST tolerate several events sharing one `created_at` — a timestamp-only cursor silently skips same-second events, so the cursor MUST break ties on event id.
+1. **Enumerate exhaustively.** Retrieve the latest live head of every `kind:30621` and `kind:30617` coordinate, plus the `kind:5` deletions bearing on them, using paginated queries that run to exhaustion. A fixed `limit` MUST NOT be used: with a limit of 200, repository 201 vanishes from the collection, which is precisely the compatibility guarantee this NIP owes existing repositories. What "to exhaustion" costs depends on the cursor the relay offers — see [Pagination](#pagination).
 2. **Resolve members.** For each project, resolve each member coordinate to its repository head, and determine whether the project [claims](#claim-authority) each one.
 3. **Suppress claimed implicit cards.** A live repository claimed by at least one project does not also render as an implicit single-repository card.
 4. **Render multiple membership.** A repository belonging to several listing-eligible projects renders inside each of them, claimed or not.
@@ -221,7 +235,7 @@ The fold is deterministic: same heads in, same collection out, independent of ar
 
 ### Required fold cases
 
-The fold cannot be expressed as accept/reject of a single event, so it is not in the [conformance fixtures](#conformance-fixtures) — those are the ingest contract. A client implementing the fold MUST cover at least these cases, each of which is a distinct branch above:
+The fold cannot be expressed as accept/reject of a single event, so it has its own fixture file rather than living in the ingest [conformance fixtures](#conformance-fixtures). A client implementing the fold MUST cover at least these cases, each of which is a distinct branch above:
 
 | Case | Expected collection |
 |------|---------------------|
@@ -234,8 +248,21 @@ The fold cannot be expressed as accept/reject of a single event, so it is not in
 | Viewer has hidden a member repository | Repository absent from the collection *and* from inside every project listing it |
 | Member coordinate resolves to nothing | Member renders inside its project as unavailable; no standalone card |
 | Project head deleted | Project absent; its members render as implicit cards |
+| One authorized and one unauthorized project both list the same repository | Repository renders inside both projects; no implicit card, because one claim suffices to suppress it |
 | More repositories and projects than one page holds, with several sharing one `created_at` | Every repository and project renders |
 
+[`NIP-MP.fold-fixtures.json`](NIP-MP.fold-fixtures.json) mechanizes this table — see [Conformance Fixtures](#conformance-fixtures).
+
+### Pagination
+
+Step 1's "to exhaustion" is a guarantee about the result, not a single algorithm: what a client must do to reach it depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports and MUST NOT present a mode-1 loop's output as complete on a mode-2 relay.
+
+**Mode 1 — composite cursor (exhaustive).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Because the pair is unique per event, each page resumes exactly where the last ended, so the loop terminates having seen every matching event regardless of how many share a timestamp.
+
+**Mode 2 — `until` only (bounded, not guaranteed).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither available step is safe on its own: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. Exhaustive enumeration is therefore guaranteed only when every equal-`created_at` bucket fits in one response.
+
+A mode-2 client MUST detect that condition rather than assume it: when a page returns `limit` events that all share one `created_at`, the bucket may be truncated and the collection is possibly incomplete. On detecting it, a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
+
 ### Collection growth
 
 Step 1's exhaustive enumeration is a correctness floor, not a scaling strategy: it says a client MUST NOT silently truncate its collection, because a repository absent from the list is indistinguishable from one that does not exist. It is not a mandate to hold the relay's entire repository set in memory on every load.
@@ -250,12 +277,22 @@ Legacy `<owner>:<dtag>` repository routes remain valid and resolve to that repos
 
 ## Conformance Fixtures
 
-[`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) holds the shared valid/invalid case set: 9 accepted and 16 rejected events covering minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, and each rejection rule above.
+Two fixture files carry the machine-checkable contract. Neither has consumers yet; each states what its consumers are required to do.
+
+### Ingest
 
-The relay validator, the Rust builder, and the TypeScript builder all test against this one file, so a divergence between them is a test failure rather than a production surprise.
+[`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) holds the shared valid/invalid case set: 11 accepted and 19 rejected events covering minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, relay hints, non-empty `content`, and each rejection rule above.
+
+The relay validator, the Rust builder, and the TypeScript builder are required to test against this one file, so a divergence between them is a test failure rather than a production surprise.
 
 Each case carries an **unsigned** template — `kind`, `content`, `tags`. Consumers sign it with their own test key. Signed literals would be inert: the id and signature are fixed by the exact serialization, so any consumer that re-serializes would need to recompute both anyway. Rejection cases name their `reject_rules`, so an implementation cannot pass by rejecting a bad event for an unrelated reason.
 
+### Fold
+
+[`NIP-MP.fold-fixtures.json`](NIP-MP.fold-fixtures.json) holds the oracle for [the fold](#the-fold): 12 cases covering every row of the [required fold cases](#required-fold-cases) table. Every client implementing the fold is required to test against this one file. The fold is where the [claim authority](#claim-authority) rule lives, so without a shared oracle two clients could each satisfy the prose and still render different collections from identical heads.
+
+Its cases are **semantic, not signed envelopes**. A repository or project is named by its coordinate plus the inputs the fold actually reads — signer, members, `maintainers`, visibility, viewer-hidden, deletion. Signing would test the ingest contract a second time and obscure what is under test: this file assumes every input is an already-accepted head and pins only the placement derived from it. Each case gives `expect.containers` (each rendered project with the members rendered inside it) and `expect.implicit_cards` (the repositories that additionally render as their own cards); both are compared as sets, since the fold fixes placement and not order.
+
 ## Security Considerations
 
 **Unauthorized grouping claims are the accepted trade.** Anyone may publish a project referencing anyone's repositories. That claim is a signed statement attributable to its author and grants nothing ([Authority](#authority)) — the same trust model as NIP-51 lists, which likewise reference content their author does not own. A client MUST NOT present membership in a stranger's project as endorsement by, or authority over, the member repository's owner, and MUST show the project signer alongside a project it did not author.
@@ -267,7 +304,7 @@ Each case carries an **unsigned** template — `kind`, `content`, `tags`. Consum
 ## Relation to Other NIPs
 
 - **NIP-34**: Supplies the member repositories. Members are `kind:30617` announcements referenced by coordinate; a NIP-34 client that does not know `kind:30621` still discovers and renders each repository normally.
-- **NIP-33**: Supplies addressing, replacement, and the owner-only editing model. Owner-only editing is not enforcement code in Buzz — it is what NIP-33 replacement already means.
+- **NIP-01**: Supplies the addressable-event class, the `a` tag grammar, addressing, replacement, and the owner-only editing model. Owner-only editing is not enforcement code in Buzz — it is what NIP-01 replacement already means.
 - **NIP-09**: Supplies container deletion, which deletes the container only. Buzz extends it in two ways that are not project-specific: an agent's registered NIP-OA owner may also delete, and a tombstone applies only at or before its own `created_at` ([Deletion](#deletion)).
 - **NIP-29**: Supplies the channel a project's `buzz-channel` names. The reference is metadata; project state is never channel-scoped.
 - **NIP-51**: The closest existing precedent — a signed, addressable list referencing content the author need not own. Not reused because a project is a shared named forge container with its own channel binding and visibility, not a user's private-or-public bookmark set.

From 185c9f85dea78ca7ffcbcf927501213cb6c71fc6 Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Tue, 28 Jul 2026 14:48:40 -0400
Subject: [PATCH 04/10] docs(nips): drain the boundary bucket and enforce
 member tag arity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The mode-2 pagination detector only fired when a whole page shared one
`created_at`, so a full page whose oldest second was partly returned
advanced past the undelivered remainder — a silent gap in the exhaustive
enumeration the fold requires. Mode 2 now drains the boundary second
explicitly before stepping below it, which makes enumeration exhaustive
whenever a bucket fits in one response and turns truncation detection
from a heuristic into an exact test.

Member `a` tag arity was stated as NIP-01's two-or-three-element grammar
but validated nowhere: a fourth element rode in unvalidated because
ingest read element 1 and ignored the rest. Arity becomes its own rule
rather than folding into `member-coordinate-malformed`, since a
four-element tag can carry a perfectly valid coordinate — the shape is
what is wrong. Relay enforcement lands with the fixture wiring, as the
two `buzz-` metadata bounds already do.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.fixtures.json      | 26 +++++++++++++++++++++-
 docs/nips/NIP-MP.fold-fixtures.json |  2 +-
 docs/nips/NIP-MP.md                 | 34 +++++++++++++++++++----------
 3 files changed, 48 insertions(+), 14 deletions(-)

diff --git a/docs/nips/NIP-MP.fixtures.json b/docs/nips/NIP-MP.fixtures.json
index dedd161f1f..cfc570ec65 100644
--- a/docs/nips/NIP-MP.fixtures.json
+++ b/docs/nips/NIP-MP.fixtures.json
@@ -462,7 +462,7 @@
     {
       "name": "valid_member_relay_hint",
       "expect": "accept",
-      "note": "Member `a` tag carries NIP-01's optional third element, a relay hint. Ingest validates the coordinate in element 1 only, so the hint is neither validated nor a rejection cause; a client MAY use it to resolve an otherwise unavailable member.",
+      "note": "Member `a` tag carries NIP-01's optional third element, a relay hint. Ingest validates the tag's arity and the coordinate in element 1, so the hint's content is neither parsed nor a rejection cause; a client MAY use it to resolve an otherwise unavailable member.",
       "template": {
         "kind": 30621,
         "content": "",
@@ -1162,6 +1162,30 @@
         ]
       }
     },
+    {
+      "name": "invalid_member_tag_arity_four_elements",
+      "expect": "reject",
+      "note": "Member `a` tag carries a fourth element past the relay hint. Its coordinate is valid, so this rejects on tag shape rather than on the coordinate: NIP-01's `a` grammar is two or three elements, and a validator that reads element 1 and ignores the rest would accept unbounded unvalidated data in a position no consumer reads.",
+      "reject_rules": [
+        "member-tag-arity"
+      ],
+      "template": {
+        "kind": 30621,
+        "content": "",
+        "tags": [
+          [
+            "d",
+            "platform"
+          ],
+          [
+            "a",
+            "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz",
+            "wss://relay.example.com",
+            "unexpected"
+          ]
+        ]
+      }
+    },
     {
       "name": "invalid_member_kind_prefix",
       "expect": "reject",
diff --git a/docs/nips/NIP-MP.fold-fixtures.json b/docs/nips/NIP-MP.fold-fixtures.json
index 3eb1b0a045..c036646c34 100644
--- a/docs/nips/NIP-MP.fold-fixtures.json
+++ b/docs/nips/NIP-MP.fold-fixtures.json
@@ -1,5 +1,5 @@
 {
-  "$comment": "NIP-MP fold fixtures \u2014 the shared oracle for the client-side fold in docs/nips/NIP-MP.md (\"The fold\" and \"Required fold cases\"). Every case in the spec's required-cases table appears here, keyed by `spec_case`. Inputs are SEMANTIC, not signed envelopes: a repository or project is named by its coordinate plus the fold's inputs (signer, members, `maintainers`, visibility, viewer-hidden, deletion). Signing and envelope validation are the ingest contract and live in NIP-MP.fixtures.json; this file assumes every input is a valid, accepted head and tests only the placement the fold derives from it. `expect.containers` lists each rendered project with the members rendered inside it; `expect.implicit_cards` lists the repositories that additionally render as their own single-repository cards. Order is not significant in either list \u2014 compare as sets. `render` is `resolved` when the member coordinate resolves to a live head and `unavailable` when it does not. `state` is `live` or `deleted`; `visibility` is `listed` or `unlisted`; a value of `viewer_hidden` is a local, per-viewer decision, not event state.",
+  "$comment": "NIP-MP fold fixtures \u2014 the shared oracle for the client-side fold in docs/nips/NIP-MP.md (\"The fold\" and \"Required fold cases\"). Every case in the spec's required-cases table appears here, keyed by `spec_case`. Inputs are SEMANTIC, not signed envelopes: a repository or project is named by its coordinate plus the fold's inputs (signer, members, `maintainers`, visibility, viewer-hidden, deletion). Signing and envelope validation are the ingest contract and live in NIP-MP.fixtures.json; this file assumes every input is a valid, accepted head and tests only the placement the fold derives from it. `expect.containers` lists each rendered project with the members rendered inside it; `expect.implicit_cards` lists the repositories that additionally render as their own single-repository cards. EVERY collection in `expect` is compared as a set \u2014 the containers, each container's `members`, and the implicit cards alike \u2014 because the fold fixes placement, not order. `render` is `resolved` when the member coordinate resolves to a live head and `unavailable` when it does not. `state` is `live` or `deleted`; `visibility` is `listed` or `unlisted`; a value of `viewer_hidden` is a local, per-viewer decision, not event state.",
   "version": 1,
   "project_kind": 30621,
   "repository_kind": 30617,
diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index 7d92d17313..c92e6ee5a7 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -107,7 +107,7 @@ Ingest bounds metadata cardinality and length; it interprets no metadata value.
 
 ### Member coordinates
 
-A member `a` tag follows NIP-01's `a` tag grammar: `["a", "<coordinate>"]` or `["a", "<coordinate>", "<relay-url>"]`. Ingest validates the coordinate — element 1 — and nothing else.
+A member `a` tag follows NIP-01's `a` tag grammar: `["a", "<coordinate>"]` or `["a", "<coordinate>", "<relay-url>"]`. Ingest validates the tag's arity — exactly two or three elements — and the coordinate in element 1. The relay URL is opaque: it is never parsed and never a rejection cause by content. A fourth element has no meaning in this grammar and is rejected rather than ignored, so a writer cannot smuggle unbounded data into a position no consumer reads.
 
 The optional third element is a **relay hint**: a recommended relay where the member announcement may be found. Clients MAY use it when resolving a member that step 6 of [the fold](#the-fold) would otherwise mark unavailable, and MUST treat it as advice rather than authority — a hint is unauthenticated, supplied by the project signer rather than the repository owner, so a resolution through it MUST still verify that the retrieved event is the coordinate's own signed `kind:30617`. A hint MUST NOT be required: a project whose members are all on the reading relay resolves fully without one, and a client that ignores hints entirely is conformant.
 
@@ -173,14 +173,15 @@ A relay accepting `kind:30621` MUST validate the envelope at ingest. The rule na
 1. **`d-cardinality`** — exactly one `d` tag. Zero or several is rejected. Under NIP-01 a missing `d` is treated as empty, which collapses every such event into the `(pubkey, 30621, "")` slot where unrelated projects silently overwrite each other; several `d` tags make the address reader-dependent.
 2. **`d-empty`** — the `d` value is non-empty. Same collapse hazard. Its length is bounded by the relay's existing generic `d`-tag limit (`buzz_db::event::D_TAG_MAX_LEN`, 1024 bytes); this NIP adds no second bound.
 3. **`member-cap`** — at most 64 member `a` tags, counting **every** `a` tag rather than distinct coordinates. Counting distinct coordinates would leave parse volume bounded only by the relay frame limit (512 KiB by default, `crates/buzz-relay/src/config.rs`), since a duplicate-heavy event could carry thousands of tags naming one coordinate. The cap is inclusive: 64 is accepted, 65 is not.
-4. **`member-coordinate-malformed`** — every member `a` tag's coordinate (element 1) parses per [Member coordinates](#member-coordinates). A relay hint in element 3 is not validated and MUST NOT be a rejection cause.
-5. **`member-duplicate`** — no two member `a` tags hold the same coordinate, compared as exact strings on the canonical form. Comparison is on the coordinate alone, so two tags naming one coordinate with different relay hints are duplicates.
-6. **`metadata-cardinality`** — at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility`. Duplicates would make the effective value reader-dependent.
-7. **`metadata-length`** — `name` at most 256 bytes; `description` at most 2048 bytes; `buzz-channel` at most 256 bytes; `buzz-visibility` at most 256 bytes. The two `buzz-` bounds are generous by design: neither value has a semantic length, and the bound exists only so an unbounded string cannot ride into storage on a tag ingest does not interpret.
+4. **`member-tag-arity`** — every member `a` tag has exactly two or three elements, per NIP-01's `a` tag grammar. A one-element tag names no coordinate; a fourth element has no defined meaning, and ignoring it would let a writer park unbounded unvalidated data in a position no consumer reads. This is a separate rule from the next one because the failure is different: the tag's shape is wrong, not the coordinate it holds.
+5. **`member-coordinate-malformed`** — every member `a` tag's coordinate (element 1) parses per [Member coordinates](#member-coordinates). The relay hint in element 3 is not parsed and MUST NOT be a rejection cause by its content.
+6. **`member-duplicate`** — no two member `a` tags hold the same coordinate, compared as exact strings on the canonical form. Comparison is on the coordinate alone, so two tags naming one coordinate with different relay hints are duplicates.
+7. **`metadata-cardinality`** — at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility`. Duplicates would make the effective value reader-dependent.
+8. **`metadata-length`** — `name` at most 256 bytes; `description` at most 2048 bytes; `buzz-channel` at most 256 bytes; `buzz-visibility` at most 256 bytes. The two `buzz-` bounds are generous by design: neither value has a semantic length, and the bound exists only so an unbounded string cannot ride into storage on a tag ingest does not interpret.
 
-Rules 3 through 5 are evaluated in that order, so an oversized tag list is refused on count before any set proportional to it is built.
+Rules 3 through 6 are evaluated in that order, so an oversized tag list is refused on count before any per-tag parse or set proportional to it is built.
 
-The `buzz-channel` and `buzz-visibility` bounds in rule 7 land in the Buzz validator together with the fixture wiring that exercises them; the validator bounds `name` and `description` today.
+Three checks land in the Buzz validator together with the fixture wiring that exercises them: the `buzz-channel` and `buzz-visibility` bounds in rule 8, and rule 4's arity. The validator bounds `name` and `description` today, and reads element 1 of each member `a` tag while ignoring any element past it.
 
 **Duplicates are rejected, never normalized.** A relay cannot dedupe tags inside a signed event: rewriting the tag array changes the event id and invalidates the signature. The choices are reject, or accept and require every present and future consumer to apply a first-wins interpretation rule. Rejecting keeps every stored head canonical and spares all consumers a defensive parse.
 
@@ -231,7 +232,7 @@ Given the set of repositories and projects to render, a client MUST derive the c
 6. **Mark unresolvable members.** A member coordinate that resolves to nothing — never announced, deleted, or not present on this relay — renders inside its project as explicitly unavailable. It MUST NOT become a phantom standalone card, and it MUST NOT be silently dropped: silence makes a project look smaller than its author declared.
 7. **Hiding a container never hides repositories.** Locally hiding a project makes it not listing eligible, so it claims nothing and by step 5 its members return as implicit cards. Hiding a grouping is a statement about the grouping. A repository disappears from the collection only when the viewer hides that repository or it is deleted — and a repository the viewer has hidden is hidden everywhere, including inside every project that lists it, so hiding one cannot be undone by someone else's grouping.
 
-The fold is deterministic: same heads in, same collection out, independent of arrival order or query shape. Every live, unhidden repository renders in at least one place — inside a project that claims it, or as its own card — and no repository renders twice within one container.
+The fold is deterministic: same heads in, same collection out, independent of arrival order or query shape. **Placement, not order, is what the fold fixes** — the collection of containers, the members rendered inside each container, and the implicit cards are all compared as sets, since member order is not significant in the event ([Event Format](#event-format)) and a client is free to sort its own presentation. Every live, unhidden repository renders in at least one place — inside a project that claims it, or as its own card — and no repository renders twice within one container.
 
 ### Required fold cases
 
@@ -259,9 +260,18 @@ Step 1's "to exhaustion" is a guarantee about the result, not a single algorithm
 
 **Mode 1 — composite cursor (exhaustive).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Because the pair is unique per event, each page resumes exactly where the last ended, so the loop terminates having seen every matching event regardless of how many share a timestamp.
 
-**Mode 2 — `until` only (bounded, not guaranteed).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither available step is safe on its own: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. Exhaustive enumeration is therefore guaranteed only when every equal-`created_at` bucket fits in one response.
+**Mode 2 — `until` only (exhaustive per bucket, with exact truncation detection).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it:
 
-A mode-2 client MUST detect that condition rather than assume it: when a page returns `limit` events that all share one `created_at`, the bucket may be truncated and the collection is possibly incomplete. On detecting it, a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
+1. A page returning fewer than `limit` events means the query is exhausted — stop.
+2. After a **full** page, let `oldest` be the smallest `created_at` it returned. Query that second exactly — `since = until = oldest` — and merge the result into what is already held, deduplicating by event id.
+3. If that bucket query itself returns `limit` events, second `oldest` may hold more than the relay will return in one response, so the collection MUST be marked possibly incomplete. Count `limit` inclusively: a bucket holding exactly `limit` events is indistinguishable from a larger one, and over-reporting a doubt is the safe direction.
+4. Once the bucket query returns fewer than `limit` events, the second is fully drained. Set `until = oldest - 1` and continue from step 1.
+
+A client that cannot drain a bucket has lost exhaustiveness for that second and MUST keep the collection marked possibly incomplete; it MAY still set `until = oldest - 1` to gather the older events rather than stall, but MUST NOT clear the mark by doing so.
+
+The naive form fails on a page whose oldest second is only partly returned, which a same-`created_at` test on the page as a whole does not see. With `limit = 3` over `(100,a) (99,b) (99,c) (99,d) (98,e)`, the first page is `(100,a) (99,b) (99,c)` — three distinct timestamps, so no all-tied heuristic fires — and advancing to `until = 98` silently drops `(99,d)`. Draining second `99` first retrieves it.
+
+Enumeration is therefore exhaustive whenever every equal-`created_at` bucket fits in one response, and truncation is detected exactly rather than guessed at. On detecting it, a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
 
 ### Collection growth
 
@@ -281,7 +291,7 @@ Two fixture files carry the machine-checkable contract. Neither has consumers ye
 
 ### Ingest
 
-[`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) holds the shared valid/invalid case set: 11 accepted and 19 rejected events covering minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, relay hints, non-empty `content`, and each rejection rule above.
+[`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) holds the shared valid/invalid case set: 11 accepted and 20 rejected events covering minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, relay hints, non-empty `content`, and each rejection rule above.
 
 The relay validator, the Rust builder, and the TypeScript builder are required to test against this one file, so a divergence between them is a test failure rather than a production surprise.
 
@@ -291,7 +301,7 @@ Each case carries an **unsigned** template — `kind`, `content`, `tags`. Consum
 
 [`NIP-MP.fold-fixtures.json`](NIP-MP.fold-fixtures.json) holds the oracle for [the fold](#the-fold): 12 cases covering every row of the [required fold cases](#required-fold-cases) table. Every client implementing the fold is required to test against this one file. The fold is where the [claim authority](#claim-authority) rule lives, so without a shared oracle two clients could each satisfy the prose and still render different collections from identical heads.
 
-Its cases are **semantic, not signed envelopes**. A repository or project is named by its coordinate plus the inputs the fold actually reads — signer, members, `maintainers`, visibility, viewer-hidden, deletion. Signing would test the ingest contract a second time and obscure what is under test: this file assumes every input is an already-accepted head and pins only the placement derived from it. Each case gives `expect.containers` (each rendered project with the members rendered inside it) and `expect.implicit_cards` (the repositories that additionally render as their own cards); both are compared as sets, since the fold fixes placement and not order.
+Its cases are **semantic, not signed envelopes**. A repository or project is named by its coordinate plus the inputs the fold actually reads — signer, members, `maintainers`, visibility, viewer-hidden, deletion. Signing would test the ingest contract a second time and obscure what is under test: this file assumes every input is an already-accepted head and pins only the placement derived from it. Each case gives `expect.containers` (each rendered project with the members rendered inside it) and `expect.implicit_cards` (the repositories that additionally render as their own cards). Every collection in `expect` is compared as a set — the containers, each container's `members`, and the implicit cards alike — because the fold fixes placement and not order.
 
 ## Security Considerations
 

From 149e8c72b19db17f392214ab985bbb3a39666c66 Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Tue, 28 Jul 2026 15:15:09 -0400
Subject: [PATCH 05/10] docs(nips): correct timestamp count in NIP-MP
 pagination example
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The mixed-page counterexample described its first page as carrying three
distinct timestamps when it holds three events across two. The tuples
already prove the point the sentence makes — that the page is not all
tied — so only the count was wrong.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index c92e6ee5a7..561623787c 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -269,7 +269,7 @@ Step 1's "to exhaustion" is a guarantee about the result, not a single algorithm
 
 A client that cannot drain a bucket has lost exhaustiveness for that second and MUST keep the collection marked possibly incomplete; it MAY still set `until = oldest - 1` to gather the older events rather than stall, but MUST NOT clear the mark by doing so.
 
-The naive form fails on a page whose oldest second is only partly returned, which a same-`created_at` test on the page as a whole does not see. With `limit = 3` over `(100,a) (99,b) (99,c) (99,d) (98,e)`, the first page is `(100,a) (99,b) (99,c)` — three distinct timestamps, so no all-tied heuristic fires — and advancing to `until = 98` silently drops `(99,d)`. Draining second `99` first retrieves it.
+The naive form fails on a page whose oldest second is only partly returned, which a same-`created_at` test on the page as a whole does not see. With `limit = 3` over `(100,a) (99,b) (99,c) (99,d) (98,e)`, the first page is `(100,a) (99,b) (99,c)` — two distinct timestamps, so no all-tied heuristic fires — and advancing to `until = 98` silently drops `(99,d)`. Draining second `99` first retrieves it.
 
 Enumeration is therefore exhaustive whenever every equal-`created_at` bucket fits in one response, and truncation is detected exactly rather than guessed at. On detecting it, a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
 

From 258b9b58471480a15e421f27278689641a1cc6c3 Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Wed, 29 Jul 2026 11:30:16 -0400
Subject: [PATCH 06/10] docs(nips): condition NIP-MP mode-2 exhaustiveness on a
 relay guarantee
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

NIP-01 makes `limit` advisory — "Returning fewer events is acceptable" — so
reading a short response as exhaustion is a relay property, not a protocol
one. Mode 2's stop and advance steps both relied on that inference without
stating it, overclaiming exact truncation detection on any vanilla relay.
The guarantee is now named against the effective page limit (a clamped
request answered in full is short without being exhausted), and a client
without it MUST mark the collection possibly incomplete regardless of
response sizes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.md | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index 561623787c..87efe77c3b 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -196,7 +196,7 @@ Three checks land in the Buzz validator together with the fixture wiring that ex
 **Deletion** follows NIP-09 with two Buzz-wide behaviors that are not project-specific:
 
 - A `kind:5` naming the coordinate deletes it when signed by the project signer **or** by that signer's registered NIP-OA owner ([Deletion](#deletion)).
-- The deletion applies only to versions whose `created_at` is at or before the deletion's own, per NIP-09. A delayed or replayed tombstone signed before the current head MUST NOT remove it; the relay compares timestamps at the coordinate (`soft_delete_by_coordinate`, `crates/buzz-db/src/event.rs`, whose inclusive `created_at <= <deletion>` bound is introduced alongside this specification in [#3171](https://github.com/block/buzz/pull/3171)).
+- The deletion applies only to versions whose `created_at` is at or before the deletion's own, per NIP-09. A delayed or replayed tombstone signed before the current head MUST NOT remove it; the relay MUST compare timestamps at the coordinate (`soft_delete_by_coordinate`, `crates/buzz-db/src/event.rs`, whose inclusive `created_at <= <deletion>` bound is introduced alongside this specification in [#3171](https://github.com/block/buzz/pull/3171)).
 
 ## Client Behavior
 
@@ -260,18 +260,20 @@ Step 1's "to exhaustion" is a guarantee about the result, not a single algorithm
 
 **Mode 1 — composite cursor (exhaustive).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Because the pair is unique per event, each page resumes exactly where the last ended, so the loop terminates having seen every matching event regardless of how many share a timestamp.
 
-**Mode 2 — `until` only (exhaustive per bucket, with exact truncation detection).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it:
+**Mode 2 — `until` only (boundary-bucket drain; exhaustive only under a relay short-response guarantee).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it.
+
+**The guarantee this mode rests on.** Every exhaustion inference below reads a short response as "nothing further matches" — and that is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. This mode is exhaustive only on a relay that guarantees **a response shorter than the effective page limit means no further events match the filter** — the effective page limit being the smaller of the requested `limit` and any cap the relay imposes on it, since a clamped request answered in full is short without being exhausted. Buzz's relay makes that guarantee. Absent it, a mode-2 client MUST mark the collection possibly incomplete regardless of any response size, and the drain below serves to reduce silent loss rather than to eliminate it. `limit` below means the effective page limit.
 
 1. A page returning fewer than `limit` events means the query is exhausted — stop.
-2. After a **full** page, let `oldest` be the smallest `created_at` it returned. Query that second exactly — `since = until = oldest` — and merge the result into what is already held, deduplicating by event id.
-3. If that bucket query itself returns `limit` events, second `oldest` may hold more than the relay will return in one response, so the collection MUST be marked possibly incomplete. Count `limit` inclusively: a bucket holding exactly `limit` events is indistinguishable from a larger one, and over-reporting a doubt is the safe direction.
-4. Once the bucket query returns fewer than `limit` events, the second is fully drained. Set `until = oldest - 1` and continue from step 1.
+2. After a **full** page, let `oldest` be the smallest `created_at` it returned. Query that second exactly — `since = until = oldest` — and merge the result into what is already held, deduplicating by event id. That single bucket query has two outcomes.
+3. If it returns `limit` events, second `oldest` may hold more than the relay will return in one response, so the collection MUST be marked possibly incomplete. Count `limit` inclusively: a bucket holding exactly `limit` events is indistinguishable from a larger one, and over-reporting a doubt is the safe direction.
+4. If instead it returns fewer than `limit` events, the second is fully drained. Set `until = oldest - 1` and continue from step 1.
 
 A client that cannot drain a bucket has lost exhaustiveness for that second and MUST keep the collection marked possibly incomplete; it MAY still set `until = oldest - 1` to gather the older events rather than stall, but MUST NOT clear the mark by doing so.
 
 The naive form fails on a page whose oldest second is only partly returned, which a same-`created_at` test on the page as a whole does not see. With `limit = 3` over `(100,a) (99,b) (99,c) (99,d) (98,e)`, the first page is `(100,a) (99,b) (99,c)` — two distinct timestamps, so no all-tied heuristic fires — and advancing to `until = 98` silently drops `(99,d)`. Draining second `99` first retrieves it.
 
-Enumeration is therefore exhaustive whenever every equal-`created_at` bucket fits in one response, and truncation is detected exactly rather than guessed at. On detecting it, a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
+Enumeration is therefore exhaustive when the relay makes the short-response guarantee above and every equal-`created_at` bucket fits in one response; under those conditions truncation is detected exactly rather than guessed at. On detecting it — or on any relay that does not make the guarantee — a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
 
 ### Collection growth
 

From 5602d724c6a4bfa5abd0ed0c301cdba8e7404080 Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Wed, 29 Jul 2026 13:15:03 -0400
Subject: [PATCH 07/10] docs(nips): remove false relay guarantee, make mode-2
 contract client-observable

Fix A: Delete the claim that Buzz's relay provides the mode-2 short-response
guarantee. That assertion was false at the reviewed head: post-filtered query
shapes (kinds+#a tag tombstone lookups) can return fewer than the effective
limit while matching events still exist beyond the SQL window, and the
advertised NIP-11 cap (10,000) differs from both the REQ-layer cap (2,000)
and the DB cap (1,000), making the effective limit undecidable by the client.

Replace the Buzz assertion with two client-observable conditions that together
constitute the guarantee: (1) the relay applies the complete filter before
enforcing any limit, and (2) the relay exposes the exact effective page limit
it enforces. A relay satisfying only one does not provide the guarantee; absent
it the unconditional possibly-incomplete MUST governs.

Fix B: Condition the fold-level exhaustive-enumeration requirement (step 1)
and the pagination preamble's conformance sentence so they do not contradict
the mode-2 fallback. Step 1 now reads 'Enumerate exhaustively when possible'
and names the marked-partial path as the conformant fallback on a relay that
provides no exhaustive mode. The pagination preamble now states that whether
the target result is reachable depends on the cursor the relay offers, and that
presenting a marked-partial collection is conformant rather than a violation of
step 1's enumeration requirement on such a relay.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index 87efe77c3b..93ec08c779 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -224,7 +224,7 @@ An unauthorized project still renders, and still renders its members inside itse
 
 Given the set of repositories and projects to render, a client MUST derive the collection as follows.
 
-1. **Enumerate exhaustively.** Retrieve the latest live head of every `kind:30621` and `kind:30617` coordinate, plus the `kind:5` deletions bearing on them, using paginated queries that run to exhaustion. A fixed `limit` MUST NOT be used: with a limit of 200, repository 201 vanishes from the collection, which is precisely the compatibility guarantee this NIP owes existing repositories. What "to exhaustion" costs depends on the cursor the relay offers — see [Pagination](#pagination).
+1. **Enumerate exhaustively when possible.** Retrieve the latest live head of every `kind:30621` and `kind:30617` coordinate, plus the `kind:5` deletions bearing on them, using paginated queries. A fixed `limit` MUST NOT be used: with a limit of 200, repository 201 vanishes from the collection, which is precisely the compatibility guarantee this NIP owes existing repositories. What "to exhaustion" means depends on the cursor the relay offers — see [Pagination](#pagination). On a relay that does not provide an exhaustive mode, a client MUST mark the collection possibly incomplete rather than present a partial result as complete.
 2. **Resolve members.** For each project, resolve each member coordinate to its repository head, and determine whether the project [claims](#claim-authority) each one.
 3. **Suppress claimed implicit cards.** A live repository claimed by at least one project does not also render as an implicit single-repository card.
 4. **Render multiple membership.** A repository belonging to several listing-eligible projects renders inside each of them, claimed or not.
@@ -256,13 +256,13 @@ The fold cannot be expressed as accept/reject of a single event, so it has its o
 
 ### Pagination
 
-Step 1's "to exhaustion" is a guarantee about the result, not a single algorithm: what a client must do to reach it depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports and MUST NOT present a mode-1 loop's output as complete on a mode-2 relay.
+Step 1's "to exhaustion" describes the target result, not a single algorithm: what a client must do — and whether it can fully reach it — depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports, MUST NOT present a mode-1 loop's output as complete on a mode-2 relay, and on a relay that provides neither mode 1 nor the mode-2 guarantee below, MUST mark the collection possibly incomplete — presenting that marked partial collection is conformant, not a violation of step 1's enumeration requirement.
 
 **Mode 1 — composite cursor (exhaustive).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Because the pair is unique per event, each page resumes exactly where the last ended, so the loop terminates having seen every matching event regardless of how many share a timestamp.
 
 **Mode 2 — `until` only (boundary-bucket drain; exhaustive only under a relay short-response guarantee).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it.
 
-**The guarantee this mode rests on.** Every exhaustion inference below reads a short response as "nothing further matches" — and that is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. This mode is exhaustive only on a relay that guarantees **a response shorter than the effective page limit means no further events match the filter** — the effective page limit being the smaller of the requested `limit` and any cap the relay imposes on it, since a clamped request answered in full is short without being exhausted. Buzz's relay makes that guarantee. Absent it, a mode-2 client MUST mark the collection possibly incomplete regardless of any response size, and the drain below serves to reduce silent loss rather than to eliminate it. `limit` below means the effective page limit.
+**The guarantee this mode rests on.** Every exhaustion inference below reads a short response as "nothing further matches" — and that is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. This mode is exhaustive only on a relay that **both** (1) applies the **complete filter before enforcing any limit** — a relay that post-filters after limiting can return a short (even empty) response while older matching events sit beyond the limited window, so short responses carry no exhaustion signal there — and (2) **exposes the exact effective page limit it enforces**, so the client can evaluate "shorter than the effective limit": the effective page limit is the smaller of the requested `limit` and any cap the relay imposes, since a clamped request answered in full is short without being exhausted, but if the advertised cap differs from the enforced one, "shorter than the effective limit" is undecidable by the client. A relay satisfying only one of these conditions does not provide the guarantee. Absent it, a mode-2 client MUST mark the collection possibly incomplete regardless of any response size, and the drain below serves to reduce silent loss rather than to eliminate it. `limit` below means the effective page limit.
 
 1. A page returning fewer than `limit` events means the query is exhausted — stop.
 2. After a **full** page, let `oldest` be the smallest `created_at` it returned. Query that second exactly — `since = until = oldest` — and merge the result into what is already held, deduplicating by event id. That single bucket query has two outcomes.

From c6a372b62958a176fff552f026d7b081c6d5d81b Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Wed, 29 Jul 2026 14:34:58 -0400
Subject: [PATCH 08/10] docs(nips): hoist relay contract to govern both
 pagination modes; add query-shape guidance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Thufir's re-pass found that mode 1's exhaustiveness claim had the same flaw
we removed from mode 2: cursor uniqueness solves tied timestamps but does not
substitute for the relay contract — a post-filtering relay can return an empty
page under the composite cursor while older matching events remain beyond the
SQL candidate window.

Part 1: Hoist the relay contract (previously 'The guarantee this mode rests
on' under mode 2) to a shared 'The relay contract both modes rest on' section
above both mode descriptions. Both conditions remain unchanged: (1) complete
filter before any limit, (2) exact effective page limit exposed. Mode 1's
heading becomes 'exhaustive under the relay contract'; its body now states that
cursor uniqueness adds tie-safety within the contract but does not substitute
for it. The 'having seen every matching event' sentence, which claimed
unconditional exhaustion from cursor uniqueness alone, is gone.

Part 2: Add 'Query shapes' normative guidance after the drain close-out. A
client MUST issue fold queries in shapes the relay applies completely before
limiting; where a needed constraint (such as #a tag matching) is post-applied
by the target relay, the client MUST widen to constraints that are pushable
(e.g., kinds alone, or kinds + authors) and match the remaining criteria
client-side. This preserves the relay contract's short-response guarantee for
every query the fold issues.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.md | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index 93ec08c779..a57c47c450 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -256,13 +256,18 @@ The fold cannot be expressed as accept/reject of a single event, so it has its o
 
 ### Pagination
 
-Step 1's "to exhaustion" describes the target result, not a single algorithm: what a client must do — and whether it can fully reach it — depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports, MUST NOT present a mode-1 loop's output as complete on a mode-2 relay, and on a relay that provides neither mode 1 nor the mode-2 guarantee below, MUST mark the collection possibly incomplete — presenting that marked partial collection is conformant, not a violation of step 1's enumeration requirement.
+Step 1's "to exhaustion" describes the target result, not a single algorithm: what a client must do — and whether it can fully reach it — depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports, MUST NOT present a mode-1 loop's output as complete on a mode-2 relay, and on a relay that provides neither mode 1 nor the relay contract below, MUST mark the collection possibly incomplete — presenting that marked partial collection is conformant, not a violation of step 1's enumeration requirement.
 
-**Mode 1 — composite cursor (exhaustive).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Because the pair is unique per event, each page resumes exactly where the last ended, so the loop terminates having seen every matching event regardless of how many share a timestamp.
+**The relay contract both modes rest on.** Every "short response = done" inference — whether from a composite cursor or a drained bucket — is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. Exhaustive enumeration is possible only on a relay that satisfies **both** of these conditions, which a client can evaluate independently:
 
-**Mode 2 — `until` only (boundary-bucket drain; exhaustive only under a relay short-response guarantee).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it.
+1. The relay **applies the complete filter before enforcing any limit.** A relay that post-filters after limiting can return a short (even empty) response while older matching events sit beyond the limited window, so short responses carry no exhaustion signal on such a relay.
+2. The relay **exposes the exact effective page limit it enforces.** The effective page limit is the smaller of the requested `limit` and any relay-imposed cap, since a clamped request answered in full is short without being exhausted. If the advertised cap differs from the enforced one, "shorter than the effective limit" is undecidable by the client.
 
-**The guarantee this mode rests on.** Every exhaustion inference below reads a short response as "nothing further matches" — and that is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. This mode is exhaustive only on a relay that **both** (1) applies the **complete filter before enforcing any limit** — a relay that post-filters after limiting can return a short (even empty) response while older matching events sit beyond the limited window, so short responses carry no exhaustion signal there — and (2) **exposes the exact effective page limit it enforces**, so the client can evaluate "shorter than the effective limit": the effective page limit is the smaller of the requested `limit` and any cap the relay imposes, since a clamped request answered in full is short without being exhausted, but if the advertised cap differs from the enforced one, "shorter than the effective limit" is undecidable by the client. A relay satisfying only one of these conditions does not provide the guarantee. Absent it, a mode-2 client MUST mark the collection possibly incomplete regardless of any response size, and the drain below serves to reduce silent loss rather than to eliminate it. `limit` below means the effective page limit.
+A relay satisfying only one condition does not provide the guarantee. Absent the guarantee, a client MUST mark the collection possibly incomplete regardless of any response sizes; the modes below serve to reduce silent loss rather than eliminate it. `limit` below means the effective page limit.
+
+**Mode 1 — composite cursor (exhaustive under the relay contract).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Within the relay contract, the uniqueness of the `(created_at, id)` pair means each page resumes exactly where the last ended with no skips or re-reads, and a short page is an unambiguous end signal. Cursor uniqueness adds tie-safety; it does not substitute for the relay contract — a relay that post-filters after limiting can return an empty page under this cursor while older matching events remain beyond the candidate window.
+
+**Mode 2 — `until` only (boundary-bucket drain; exhaustive only under the relay contract).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it.
 
 1. A page returning fewer than `limit` events means the query is exhausted — stop.
 2. After a **full** page, let `oldest` be the smallest `created_at` it returned. Query that second exactly — `since = until = oldest` — and merge the result into what is already held, deduplicating by event id. That single bucket query has two outcomes.
@@ -273,7 +278,9 @@ A client that cannot drain a bucket has lost exhaustiveness for that second and
 
 The naive form fails on a page whose oldest second is only partly returned, which a same-`created_at` test on the page as a whole does not see. With `limit = 3` over `(100,a) (99,b) (99,c) (99,d) (98,e)`, the first page is `(100,a) (99,b) (99,c)` — two distinct timestamps, so no all-tied heuristic fires — and advancing to `until = 98` silently drops `(99,d)`. Draining second `99` first retrieves it.
 
-Enumeration is therefore exhaustive when the relay makes the short-response guarantee above and every equal-`created_at` bucket fits in one response; under those conditions truncation is detected exactly rather than guessed at. On detecting it — or on any relay that does not make the guarantee — a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
+Enumeration is therefore exhaustive when the relay satisfies the contract above and every equal-`created_at` bucket fits in one response; under those conditions truncation is detected exactly rather than guessed at. On detecting it — or on any relay that does not meet the contract — a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced.
+
+**Query shapes.** The relay contract applies only where the relay can apply it — and that depends on the query shape. A relay that post-filters some constraints (such as `#a` tag matching applied after the SQL `LIMIT`) cannot guarantee short-response exhaustion for queries that use those constraints. A client MUST therefore issue fold queries in shapes whose full filter the relay applies before limiting. Where a needed constraint is not applied pre-limit on the target relay, the client MUST widen the query to constraints that are — for example, enumerating all `kind:5` events by `kinds` alone, or `kinds` + `authors`, rather than adding an `#a` filter the relay post-applies — and match the remaining criteria client-side. This keeps the relay contract's short-response guarantee intact for every query the fold issues.
 
 ### Collection growth
 

From 923419ae8302b162c9871bc7d699390b09157426 Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Wed, 29 Jul 2026 16:10:55 -0400
Subject: [PATCH 09/10] docs(nips): add saturation as condition 3 of the shared
 relay contract
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The two-condition contract was necessary but not sufficient. A relay can apply
the complete filter before limiting (condition 1) and expose the exact enforced
cap (condition 2) but still voluntarily return fewer events than the cap when
matches remain — a cap bounds from above, it does not force full pages. Both
modes read a short page as 'done' and silently omit the remaining matches.

Add condition 3: the relay saturates pages, returning
min(effective page limit, remaining matching events). With all three conditions
satisfied, short page + known limit + saturation leaves no room for withheld
matches — the inference is deductively closed.

Update connective tissue: 'satisfies both' → 'all three'; 'only one condition'
→ 'any proper subset'. Note that an authoritative relay continuation/end signal
computed after complete filtering is an equivalent substitute.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.md | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index a57c47c450..44bfaeaa80 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -258,12 +258,13 @@ The fold cannot be expressed as accept/reject of a single event, so it has its o
 
 Step 1's "to exhaustion" describes the target result, not a single algorithm: what a client must do — and whether it can fully reach it — depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports, MUST NOT present a mode-1 loop's output as complete on a mode-2 relay, and on a relay that provides neither mode 1 nor the relay contract below, MUST mark the collection possibly incomplete — presenting that marked partial collection is conformant, not a violation of step 1's enumeration requirement.
 
-**The relay contract both modes rest on.** Every "short response = done" inference — whether from a composite cursor or a drained bucket — is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. Exhaustive enumeration is possible only on a relay that satisfies **both** of these conditions, which a client can evaluate independently:
+**The relay contract both modes rest on.** Every "short response = done" inference — whether from a composite cursor or a drained bucket — is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. Exhaustive enumeration is possible only on a relay that satisfies **all three** of these conditions, which a client can evaluate independently:
 
 1. The relay **applies the complete filter before enforcing any limit.** A relay that post-filters after limiting can return a short (even empty) response while older matching events sit beyond the limited window, so short responses carry no exhaustion signal on such a relay.
 2. The relay **exposes the exact effective page limit it enforces.** The effective page limit is the smaller of the requested `limit` and any relay-imposed cap, since a clamped request answered in full is short without being exhausted. If the advertised cap differs from the enforced one, "shorter than the effective limit" is undecidable by the client.
+3. The relay **saturates pages**: after applying the complete filter and cursor, it returns `min(effective page limit, remaining matching events)` events — equivalently, whenever at least the effective limit's worth of matches remain, the page is full, so a short page contains all remaining matches. A cap bounds from above; without saturation, a relay may return fewer than the cap even when matches are still available, and a short page proves nothing. An authoritative relay-provided continuation or end signal computed after complete filtering is an equivalent substitute for this response-length inference.
 
-A relay satisfying only one condition does not provide the guarantee. Absent the guarantee, a client MUST mark the collection possibly incomplete regardless of any response sizes; the modes below serve to reduce silent loss rather than eliminate it. `limit` below means the effective page limit.
+A relay satisfying any proper subset of these conditions does not provide the guarantee. Absent the guarantee, a client MUST mark the collection possibly incomplete regardless of any response sizes; the modes below serve to reduce silent loss rather than eliminate it. `limit` below means the effective page limit.
 
 **Mode 1 — composite cursor (exhaustive under the relay contract).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Within the relay contract, the uniqueness of the `(created_at, id)` pair means each page resumes exactly where the last ended with no skips or re-reads, and a short page is an unambiguous end signal. Cursor uniqueness adds tie-safety; it does not substitute for the relay contract — a relay that post-filters after limiting can return an empty page under this cursor while older matching events remain beyond the candidate window.
 

From 6c083c0038397ad504721199bfe38cd7966fbd3b Mon Sep 17 00:00:00 2001
From: Will Pfleger <pfleger.will@gmail.com>
Date: Wed, 29 Jul 2026 17:43:36 -0400
Subject: [PATCH 10/10] docs(nips): scope mode-1 Buzz cursor cite to HTTP
 bridge; name websocket silent-drop

Dawn's review (verified by Paul) found two defects in the mode-1 Buzz citation:

1. The composite (until, before_id) cursor is a Buzz REST extension on the
   authenticated HTTP bridge (bridge.rs), not on the NIP-01 websocket REQ path.
   protocol.rs deserializes each REQ filter into a standard nostr::Filter, whose
   deserializer drops unknown fields, so before_id is silently discarded on REQ
   with no error. A NIP-01 websocket client against Buzz is in mode 2, not mode 1.

2. 'Buzz does' read as a claim that Buzz satisfies the relay contract on that
   transport, which it does not (NIP-11 advertises max_limit 10,000; WS effective
   limit is 1,000). The relay bug is filed separately; the spec fix is to scope
   the citation.

Rework: retain event.rs:48-52 as the keyset mechanics example, name the HTTP
bridge transport (bridge.rs), name the websocket REQ silent-drop (protocol.rs
via nostr::Filter), state that a NIP-01 websocket client is therefore in mode 2,
and make clear that mode selection requires evaluating the relay contract per
transport. No sentence claims Buzz satisfies the three-condition relay contract.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
---
 docs/nips/NIP-MP.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md
index 44bfaeaa80..6ca1ec4749 100644
--- a/docs/nips/NIP-MP.md
+++ b/docs/nips/NIP-MP.md
@@ -266,7 +266,7 @@ Step 1's "to exhaustion" describes the target result, not a single algorithm: wh
 
 A relay satisfying any proper subset of these conditions does not provide the guarantee. Absent the guarantee, a client MUST mark the collection possibly incomplete regardless of any response sizes; the modes below serve to reduce silent loss rather than eliminate it. `limit` below means the effective page limit.
 
-**Mode 1 — composite cursor (exhaustive under the relay contract).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. Buzz does: an events query orders by `(created_at DESC, id ASC)` and accepts `until` together with `before_id`, resolving to `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`). Within the relay contract, the uniqueness of the `(created_at, id)` pair means each page resumes exactly where the last ended with no skips or re-reads, and a short page is an unambiguous end signal. Cursor uniqueness adds tie-safety; it does not substitute for the relay contract — a relay that post-filters after limiting can return an empty page under this cursor while older matching events remain beyond the candidate window.
+**Mode 1 — composite cursor (exhaustive under the relay contract).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. As an example of the cursor mechanics, Buzz implements the keyset as `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`), resolving the sort to `(created_at DESC, id ASC)`. Buzz exposes this cursor on its authenticated HTTP bridge endpoint (`crates/buzz-relay/src/api/bridge.rs`); it is not available on the NIP-01 websocket REQ path, where `before_id` is silently discarded — `protocol.rs` deserializes each REQ filter into a standard `nostr::Filter`, whose deserializer drops unknown fields, so a client sending `before_id` on a REQ receives no error and falls back to `until`-only paging without knowing it. A NIP-01 websocket client reading `kind:30621` from Buzz is therefore in mode 2, not mode 1; mode selection requires evaluating the relay contract per transport. Within the relay contract, the uniqueness of the `(created_at, id)` pair means each page resumes exactly where the last ended with no skips or re-reads, and a short page is an unambiguous end signal. Cursor uniqueness adds tie-safety; it does not substitute for the relay contract — a relay that post-filters after limiting can return an empty page under this cursor while older matching events remain beyond the candidate window.
 
 **Mode 2 — `until` only (boundary-bucket drain; exhaustive only under the relay contract).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it.
 
