feat(relay): accept kind:30621 multi-repo projects at ingest - #3171
Merged
Conversation
wpfleger96
marked this pull request as draft
July 27, 2026 18:45
wpfleger96
force-pushed
the
wpfleger96/project-kind-relay
branch
from
July 27, 2026 20:09
8d20ac1 to
cb98741
Compare
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
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. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Buzz renders one card per kind:30617, so a project spanning several repositories has no representation. NIP-MP (docs/nips/NIP-MP.md) defines kind:30621 as an addressable container holding the group's name, description, channel binding, and member coordinates. Members are `a` tags, so one project may name repositories owned by different pubkeys — the whole point of the kind — and the relay therefore performs no membership authorization: the signer gains nothing by naming a repository, because push policy reads that repository's own announcement. Owner-only editing needs no permission check either; NIP-33 addressing already keys replacement on (pubkey, kind, d). Duplicate member coordinates are rejected rather than deduped. A relay cannot rewrite tags inside a signed event without invalidating its id, so the alternative is a stored duplicate head that every consumer must apply a first-wins rule to. The member cap is counted over raw `a` tags before the duplicate set is built, so a list naming one coordinate thousands of times is refused on count rather than sized by the frame limit. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
soft_delete_by_coordinate() marked the live coordinate head deleted regardless of the tombstone's own created_at, so a delayed or replayed NIP-09 a-tag deletion signed between two versions destroyed the newer replacement. NIP-09 scopes an a-tag deletion to versions at or before the deletion request; the UPDATE now carries that predicate and handle_a_tag_deletion threads the deletion event's created_at through. events.created_at is immutable per row, so the predicate alone holds the invariant under concurrent replacement — no coordinate-level lock is needed. The bound is inclusive: a tombstone sharing the head's timestamp still deletes it. The bug predates kind:30621 and affected every parameterized-replaceable kind on the generic path, so the fix lands there rather than as a project special case. Both new tests were mutation-proved: each fails with the predicate removed. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
force-pushed
the
wpfleger96/project-kind-relay
branch
from
July 30, 2026 22:18
cb98741 to
291b99c
Compare
Add the three checks deferred from the initial NIP-MP commit: - member-tag-arity: every 'a' tag must have exactly 2 or 3 elements per NIP-01's grammar; evaluated after member-cap, before coordinate parse - metadata-length for buzz-channel: at most 256 bytes - metadata-length for buzz-visibility: at most 256 bytes Wire the shared NIP-MP.fixtures.json as a test oracle: every case is run against validate_project_envelope; a future check gap turns the suite red rather than silently passing fixture cases the code doesn't handle. Scope the event.rs concurrency doc comment: the predicate guarantees the never-delete-newer invariant; it does not guarantee deletion completeness when a same-coordinate replacement races the tombstone. The racy outcome is state-identical to a valid Nostr ordering, and the return value feeds only a debug log. Wire coordinate_delete_spares_head_newer_than_the_deletion into the Backend Integration CI job so the storage-layer invariant runs against a real Postgres instance on every push. Update NIP-MP.md: the deferral sentence is now false; state plainly that the validator enforces all eight rules and the fixtures are its test oracle. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Every rejection out of validate_project_envelope and parse_project_member_coordinate now carries its stable NIP-MP rule identifier via the new ProjectRejection type. The Display impl emits "[rule-id] message", which the ingest dispatch site forwards to the client unchanged — existing substring assertions in e2e tests are preserved. The fixture oracle (project_envelope_validates_all_shared_fixtures) now deserializes reject_rules from the fixture file and asserts that the returned rule is in the case's allowed set, making it impossible to pass a reject fixture by firing an unrelated rule. Mutation-tested: (a) a wrong rule ID turns the test red; (b) disabling any check turns it red. Two doc fixes: the NIP-MP.md fixtures-overview paragraph now distinguishes the wired relay consumer from the Phase 2 consumers still to come; the ci.yml step comment now accurately describes what the selected Postgres test proves (stale tombstone spares live head; equal-timestamp tombstone deletes it). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
tlongwell-block
approved these changes
Jul 31, 2026
wpfleger96
pushed a commit
that referenced
this pull request
Jul 31, 2026
…el-label-registry-sync * origin/main: (39 commits) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 fix(release): make immutable desktop release operable (#3943) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) ... Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96
pushed a commit
that referenced
this pull request
Jul 31, 2026
* origin/duncan/databricks-model-label-registry: feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
tlongwell-block
pushed a commit
that referenced
this pull request
Jul 31, 2026
…bsub * origin/main: fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events (#3999) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
tlongwell-block
pushed a commit
that referenced
this pull request
Jul 31, 2026
…bsub * origin/main: fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events (#3999) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
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. NIP-MP defineskind:30621as an addressable container holding a group's name, description, channel binding, and member coordinates. This adds the kind tobuzz-coreand its structural validation to the relay ingest path.Event shape
{ "kind": 30621, "tags": [ ["d", "platform"], ["name", "Platform"], ["description", "Relay, desktop, and mobile."], ["a", "30617:<owner-a-hex>:buzz"], ["a", "30617:<owner-b-hex>:buzz-infra"], ["buzz-channel", "<channel-uuid>"], ["buzz-visibility", "listed"] ] }Validation at ingest
dtagD_TAG_MAX_LENcheck)atag arityatag grammar; a 4th element has no defined meaning and is rejectedatag coordinate30617:<lowercase-64-hex-owner>:<non-empty-d>atagsname,description,buzz-channel,buzz-visibilityname≤ 256 bytes,description≤ 2048 bytes,buzz-channel≤ 256 bytes,buzz-visibility≤ 256 bytesRejection order is normative so a client can predict which rule fires:
d-cardinality →d-empty → member-cap → member-arity → coordinate parse → member-duplicate → metadata cardinality → metadata length.Design notes
No membership authorization. Members are
atags, so one project may name repositories owned by different pubkeys — the entire point of the kind. That is safe because membership grants nothing: push policy reads a repository's ownkind:30617(api/git/policy.rs) and never a project.buzz-channelis a metadata reference, not a routing directive, so projects are classified global-only.Owner-only editing is free. NIP-33 addressing keys replacement on
(pubkey, kind, d), so one signer can never overwrite another's project. No relay-side permission check exists or is needed, andtest_project_same_d_under_two_authors_are_independentpins it.Duplicates are rejected, not deduped. A relay cannot rewrite tags inside a signed event without invalidating its id and signature, so the alternative to rejection is a stored duplicate-member head that every consumer must apply a first-wins rule to.
The cap is checked before the duplicate set is built. Counting raw
atags rather than distinct coordinates means an event naming one coordinate thousands of times is refused on count, instead of being bounded only by the relay frame limit.No side-effect handler. Generic NIP-33 replacement and generic NIP-09 coordinate soft-delete already cover replacement and deletion;
kind:30621needs no entry inis_side_effect_kind.Generic NIP-09 fix carried along
soft_delete_by_coordinate(crates/buzz-db/src/event.rs) previously deleted the live coordinate head regardless of the tombstone's owncreated_at, so a delayed or replayeda-tag deletion signed between two versions destroyed the newer replacement. NIP-09 scopes ana-tag deletion to versions at or before the deletion request, so theUPDATEnow carriescreated_at <= $5andhandle_a_tag_deletionthreads the deletion event'screated_atthrough.The bug predates
kind:30621and affected every parameterized-replaceable kind on the generic path —kind:30617repository announcements included — so the fix lands there rather than as a project special case.events.created_atis immutable per row, so the predicate guarantees a tombstone can never erase a version newer than itself; the UPDATE re-evaluates its WHERE clause after any lock wait. Under READ COMMITTED, a same-coordinate replacement racing the deletion may cause the deletion to evaluate before the new head lands, returningOk(false)— but that outcome is state-identical to the deletion having arrived first, a valid Nostr ordering Nostr never fixes. The return value feeds only a debug log. No coordinate-level lock is needed.Coverage
32 unit tests in
crates/buzz-relay/src/handlers/ingest.rspin the envelope contract (accept: minimal, cross-owner, zero-member, same repodunder two owners, colon-bearing repod, cap boundary, unknown tags, relay hint on memberatag, max-length metadata, stranger-owned member, uninterpreted metadata values, non-empty content; reject: every rule above plus valuelessd/atags). A fixture-driven test (project_envelope_validates_all_shared_fixtures) runs every case in the sharedNIP-MP.fixtures.jsonoracle (11 accept + 20 reject) againstvalidate_project_envelope, so any future change that breaks a case turns the test suite red.6
#[ignore]d e2e tests incrates/buzz-test-client/tests/e2e_project.rscover behavior that only exists past storage — coordinate round-trip, newer-wins replacement, two authors sharing ad, ana-tag tombstone that removes the project while leaving referencedkind:30617s intact, and a tombstone timestamped between V1 and V2 that must leave V2 live. The negative e2e case asserts on the rejection message so a refusal for an unrelated reason cannot satisfy it; that is what proves the validator is reachable from the live write path rather than merely correct in isolation. The new e2e binary is wired into the Relay E2E job.The timestamp predicate is additionally pinned at the storage layer by
coordinate_delete_spares_head_newer_than_the_deletionincrates/buzz-db/src/lib.rs, which asserts both directions: a stale tombstone deletes nothing and leaves the newer head readable, and a tombstone at the head's own timestamp still deletes it. This test is wired into the Backend Integration job.Related: #3163 (the NIP-MP spec and shared conformance fixtures). Independent — either can merge first.
Known limitation
Coordinate (
a-tag) deletion is not yet durable against later arrival.replace_parameterized_eventnever consults stored kind-5 tombstones, so a project version withcreated_at≤ an existing tombstone that arrives after the tombstone becomes a live head again. This is a pre-existing relay-wide behavior for all parameterized-replaceable kinds — this PR neither introduced nor widened it; the timestamp guard added here strictly narrowed deletion risk in the opposite direction (a stale tombstone can no longer erase a newer head).Exposure is bounded: only the authenticated signer can write a coordinate (
ingest.rspubkey check), within the ±15-min drift window.The fix is a durable coordinate-deletion watermark extending the existing
parameterized_event_watermarkspattern (used today for NIP-RS) to cover all PRE kinds. That change requires its own schema and query design and will be a separate relay-wide PR.