docs(nips): specify kind:30621 multi-repo projects (NIP-MP) - #3163
Merged
Conversation
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>
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>
wpfleger96
force-pushed
the
wpfleger96/project-event-spec
branch
from
July 27, 2026 20:10
f1a8fa7 to
998d6b7
Compare
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>
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>
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>
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>
…-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>
… query-shape guidance 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>
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>
…ket 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>
tlongwell-block
approved these changes
Jul 30, 2026
wpfleger96
marked this pull request as ready for review
July 30, 2026 15:27
wpfleger96
added a commit
that referenced
this pull request
Jul 30, 2026
…chive * origin/main: (22 commits) feat(catalog): resolve publisher display name in catalog detail pane (#3640) feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) (#3741) docs(nips): specify kind:30621 multi-repo projects (NIP-MP) (#3163) Refine agent sharing dialog (#3699) desktop: enable getUserMedia in the Linux WebKitGTK webview (#3607) fix: align responsive agent views (#3688) Add macOS agent menu-bar menu (#3565) Fix pending message feedback (#3543) fix(desktop): remove remaining Projects panel fills (#3742) feat(mobile): desktop-parity emoji and thread experience (#3485) desktop: restore direct community member adds (#3634) fix(desktop): explain open agent access (#2561) fix(cli): resolve agents from owner records (#3178) fix(desktop): remove Projects overview card fills (#3416) feat(replica): portable heartbeat-token fence with snapshot-local reader routing (#3268) fix(git): channel binding tooling + author remediation for unbound repos (#3626) feat: configure S3 URL addressing style (#3400) feat: add first-class OpenRouter provider support (#1975) feat(agent,acp): wire provider total_tokens through NIP-AM publish chain (#3593) chore(release): release Buzz Desktop version 0.5.2 (#3624) ... Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
pushed a commit
that referenced
this pull request
Jul 30, 2026
…g-pipeline * origin/main: feat(catalog): resolve publisher display name in catalog detail pane (#3640) feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) (#3741) docs(nips): specify kind:30621 multi-repo projects (NIP-MP) (#3163) Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
joahg
added a commit
to joahg/buzz-dev-mode
that referenced
this pull request
Jul 30, 2026
…-style * origin/main: fix(acp): preserve truncated thread context (block#3340) feat(catalog): resolve publisher display name in catalog detail pane (block#3640) feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of block#3467) (block#3741) docs(nips): specify kind:30621 multi-repo projects (NIP-MP) (block#3163) Refine agent sharing dialog (block#3699) desktop: enable getUserMedia in the Linux WebKitGTK webview (block#3607) fix: align responsive agent views (block#3688) Add macOS agent menu-bar menu (block#3565) Fix pending message feedback (block#3543) fix(desktop): remove remaining Projects panel fills (block#3742) feat(mobile): desktop-parity emoji and thread experience (block#3485) desktop: restore direct community member adds (block#3634) fix(desktop): explain open agent access (block#2561) fix(cli): resolve agents from owner records (block#3178) fix(desktop): remove Projects overview card fills (block#3416) feat(replica): portable heartbeat-token fence with snapshot-local reader routing (block#3268) Signed-off-by: Joah Gerstenberg <joah@squareup.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Buzz renders one card per
kind:30617, so a project spanning several repositories has no representation — the relay, desktop app, and mobile app look like three unrelated things. This adds the spec for the container event that fixes that, plus the two shared fixture files that make it machine-checkable. Docs only; no code changes.Membership cannot live in the repository announcements themselves. A project spanning Alice's and Bob's repositories would need both of them to publish a tag naming the group, and Alice cannot sign for Bob's key. A project's own name, description, and channel binding likewise have no single writer when scattered across per-repository tags, and no deletion story. That is why multi-repo grouping is the one forge concept in Buzz that warrants a custom kind.
docs/nips/NIP-MP.mdkind:30621, an addressable event per NIP-01, addressed by(pubkey, 30621, d). Members areatags holding canonical30617:<lowercase-64-hex-owner>:<repo-d>coordinates, following NIP-01's 2-or-3-element grammar where the optional third element is a relay hint clients MAY use and whose content ingest does not parse. Metadata isname,description,buzz-channel,buzz-visibility.validate_standard_deletion_event(crates/buzz-relay/src/handlers/side_effects.rs) grants that platform-wide so a human can clean up events published by an agent they own; the spec documents it as a Buzz extension to NIP-09 rather than carvingkind:30621out of it.buzz-channelon a project is metadata only; git push policy reads the repository's ownkind:30617(crates/buzz-relay/src/api/git/policy.rs) and a project never becomes an input to it.d-cardinality,d-empty,member-cap(64, counting everyatag),member-tag-arity,member-coordinate-malformed,member-duplicate,metadata-cardinality,metadata-length. Arity is its own rule rather than part of coordinate parsing, because a four-element member tag can carry a valid coordinate — the tag's shape is what is wrong, and ignoring elements past the relay hint would admit unvalidated data no consumer reads. Duplicates are rejected rather than normalized — a relay cannot rewrite tags inside a signed event without invalidating its id and signature.nameto thedvalue, any unrecognizedbuzz-visibilitytoken tolisted(a typo is not a privacy signal), and an unresolvablebuzz-channelto a project rendered without a channel rather than dropped.contentcarries no meaning: writers SHOULD emit"", and readers and relays MUST ignore any value rather than reject it.maintainerstag. Without this, anyone could publish a project naming your repository and pull it out of the collection into a container you never consented to. An unauthorized project still renders, and still renders its members — it just cannot remove a repository from where its owner expects to find it.limit: 200makes repository 201 vanish), multiple membership, fallback to a standalone card, unresolvable members marked unavailable rather than dropped, and local hide of a container never hiding repositories. On a relay that provides no exhaustive mode, the conformant behavior is a persistently marked possibly-incomplete collection — not a violation of the enumeration requirement.min(effective limit, remaining matches), so a short page proves all remaining matches were returned. A relay satisfying any proper subset does not provide the guarantee, and absent it a client MUST mark the collection possibly incomplete. On a relay exposing a composite(created_at, event id)keyset cursor — Buzz does on its authenticated HTTP bridge endpoint, viauntil+before_id; the NIP-01 websocket REQ path silently discardsbefore_id, so a websocket client against Buzz is in mode 2 — clients MUST page by it; within the relay contract the cursor's uniqueness means no skips or re-reads and a short page is an unambiguous end signal, but cursor uniqueness alone does not substitute for the relay contract. A vanilla NIP-01 filter has no id tiebreak, sountilalone either skips a second's unread events or never advances; there a client MUST drain the boundary second explicitly. The spec also adds normative guidance on query shapes: a client MUST use only query shapes the relay applies completely before limiting, and where a needed constraint (such as#a) is post-applied, MUST widen to a pushable shape and match the rest client-side.30621is unassigned in the upstream nostr NIPs kind table and has no nostrbook.dev entry, and it is the one free number between30620and30622locally.docs/nips/NIP-MP.fixtures.jsonThe ingest contract: 31 cases — 11 accept, 20 reject — as unsigned templates consumers sign with their own test key. Coverage includes minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-
d-different-owner members, colon-bearing repositorydvalues, relay hints, non-emptycontent, and every rejection rule. Each of the two 256-bytebuzz-bounds gets its own reject case so neither can hide behind the other's rejection, and duplicate detection is pinned to the coordinate alone by a case whose two identical coordinates carry different relay hints. A four-element member tag carrying an otherwise valid coordinate pins arity separately from coordinate parsing. Every rejection case names the rules that may fire, so an implementation cannot pass by rejecting a bad event for an unrelated reason.docs/nips/NIP-MP.fold-fixtures.jsonThe fold oracle: 12 cases covering every row of the required-fold-cases table, including the discriminating case where one authorized and one unauthorized project list the same repository — an implementation that requires every listing project to be authorized emits a spurious implicit card, and one that lets any listing project suppress drops a card it owes the owner.
Inputs are semantic rather than signed envelopes: a repository or project is named by its coordinate plus only what the fold reads — signer, members,
maintainers, visibility, viewer-hidden, deletion. Every collection inexpectis compared as a set, including each container'smembers, since the fold fixes placement and not order. Signing would re-test the ingest contract and obscure what is under test. The fold is where claim authority lives, so without a shared oracle two clients could each satisfy the prose and still render different collections from identical heads.VISION_PROJECTS.mdLine 41's "zero custom kinds" now reads "no custom kind for the repo itself", with a new "One Project, Many Repos" section recording why the one exception is warranted.
30621rows added to the kind and status tables.Related: #3171 (the
KIND_PROJECTconstant, relay ingest validation of this contract, and the inclusivecreated_at <= tombstonebound this spec's coordinate-deletion rule cites). Independent — either can merge first.