Skip to content

docs(nips): specify kind:30621 multi-repo projects (NIP-MP) - #3163

Merged
wpfleger96 merged 10 commits into
mainfrom
wpfleger96/project-event-spec
Jul 30, 2026
Merged

docs(nips): specify kind:30621 multi-repo projects (NIP-MP)#3163
wpfleger96 merged 10 commits into
mainfrom
wpfleger96/project-event-spec

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 27, 2026

Copy link
Copy Markdown
Member

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.md

kind:30621, an addressable event per NIP-01, addressed by (pubkey, 30621, d). Members are a tags holding canonical 30617:<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 is name, description, buzz-channel, buzz-visibility.

  • Authority stops at the container. The signer can replace their own project and nothing else — no edit, delete, push, or admin over any member. Deletion additionally admits the signer's registered NIP-OA owner, because 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 carving kind:30621 out of it. buzz-channel on a project is metadata only; git push policy reads the repository's own kind:30617 (crates/buzz-relay/src/api/git/policy.rs) and a project never becomes an input to it.
  • Ingest validation contract, with named rules the fixtures reference: d-cardinality, d-empty, member-cap (64, counting every a tag), 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.
  • Metadata interpretation is normative, not left to the reader. Ingest bounds cardinality and length and interprets nothing; clients resolve absent name to the d value, any unrecognized buzz-visibility token to listed (a typo is not a privacy signal), and an unresolvable buzz-channel to a project rendered without a channel rather than dropped. content carries no meaning: writers SHOULD emit "", and readers and relays MUST ignore any value rather than reject it.
  • Claim authority. A project suppresses a member's standalone card only when it is listing eligible and its signer is that repository's owner or appears in the repository's own maintainers tag. 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.
  • Deterministic client fold, seven steps, with a table of required cases: exhaustive enumeration (a fixed limit: 200 makes 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.
  • Pagination is specified in two modes, because exhaustive enumeration is not universally achievable. Both modes share an explicit three-condition relay contract: a relay must (1) apply the complete filter before enforcing any limit, (2) expose the exact effective page limit it enforces, and (3) saturate pages — return 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, via until + before_id; the NIP-01 websocket REQ path silently discards before_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, so until alone 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.
  • Kind allocation recorded with the checks performed: 30621 is unassigned in the upstream nostr NIPs kind table and has no nostrbook.dev entry, and it is the one free number between 30620 and 30622 locally.

docs/nips/NIP-MP.fixtures.json

The 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 repository d values, relay hints, non-empty content, and every rejection rule. Each of the two 256-byte buzz- 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.json

The 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 in expect is compared as a set, including each container's members, 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.md

Line 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. 30621 rows added to the kind and status tables.

Related: #3171 (the KIND_PROJECT constant, relay ingest validation of this contract, and the inclusive created_at <= tombstone bound this spec's coordinate-deletion rule cites). Independent — either can merge first.

@wpfleger96
wpfleger96 requested a review from a team as a code owner July 27, 2026 17:56
@wpfleger96
wpfleger96 marked this pull request as draft July 27, 2026 18:45
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
wpfleger96 force-pushed the wpfleger96/project-event-spec branch from f1a8fa7 to 998d6b7 Compare July 27, 2026 20:10
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>
@wpfleger96
wpfleger96 marked this pull request as ready for review July 30, 2026 15:27
@wpfleger96
wpfleger96 merged commit 33bf7ca into main Jul 30, 2026
26 checks passed
@wpfleger96
wpfleger96 deleted the wpfleger96/project-event-spec branch July 30, 2026 15:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants