Lessons from Implementing NIP-34 in Flotilla-Budabit
- Repository Announcements (kind 30617)
- Repository State (kind 30618)
- Patches (kind 1617)
- Issues (kind 1621) + Comments (NIP-22)
- Status (kinds 1630–1633)
- Labels via NIP-32 (the “clunky but powerful” bit)
- Cross-cutting lessons
- Low-impact improvements we’d love (without changing NIP-34 semantics)
- Nostr-Git
- Final Take
- Links
Repository Announcements (kind 30617)
Purpose: Announce a Git repo and its collaboration policy.
{
"kind": 30617,
"content": "",
"tags": [
["d", "<repo-id>"], // required (kebab-case handle)
["name", "<human-readable name>"],
["description", "<brief description>"],
["web", "<browsing-url>", "..."], // multi
["clone", "<git-clone-url>", "..."], // multi
["relays", "<relay-url>", "..."], // multi; where patches/issues should go
["r", "<earliest-unique-commit-id>", "euc"], // fork/mirror fingerprint
["maintainers", "<npub>", "..."], // multi
["t", "<topic-tag>"] // optional, lightweight tags
]
}
What worked
relaysis a clear rendezvous point for collaboration traffic.rwith the"euc"marker is a stable colocation key. We plan to group forks/mirrors by this as our primary key.
Frictions
-
Fragmentation: Different forks publish their own 30617 (by design). Without grouping by
r:euc, naïve UIs look duplicated. -
Metadata minimalism: The spec is intentionally lean. Teams often want more (license, default branch). We kept those out of 30617 to remain interoperable, surfacing richer info from live Git or adjacent events where needed.
Budabit practice
- Group first by
r:euc; treatdas local handle(s) inside the group. - Under one “repo”, show all
web/clonefacets and the union ofmaintainers.
Repository State (kind 30618)
Purpose: Optional truth source for refs/HEAD from a publisher.
{
"kind": 30618,
"content": "",
"tags": [
["d", "<repo-id>"], // ties to the 30617
["refs/heads/<branch>", "<commit-id>"], // 0..n
["refs/tags/<tag>", "<commit-id>"], // 0..n
["HEAD", "ref: refs/heads/<branch-name>"]
]
}
Optional ancestry hint (light “ahead” info):
["refs/heads/<branch>", "<commit-id>", "<parent-short>", "<grandparent-short>", "..."]
What worked
- Being optional makes it viable across very different hosts.
- Using no
refstags as a “I stopped tracking” signal is clever and avoids delete semantics.
Frictions
- Event heft: Large repos + frequent pushes → heavy 30618 traffic. The ancestry hint helps UX but doesn’t cut bandwidth.
- Authority merging: Multiple maintainers can publish 30618. We needed a merge policy (latest per ref from a trusted author set).
Budabit practice
- Publish sparse 30618 (active refs only).
- Merge per-ref by recency bounded to recognized maintainers from a trusted 30617.
Patches (kind 1617)
Purpose: Carry git format-patch content (or a cover letter for the first in a series). Patchsets and revisions are modeled with NIP-10 e reply chains.
{
"kind": 1617,
"content": "<git format-patch>", // or cover letter on first in series
"tags": [
["a", "30617:<repo-owner-pubkey>:<repo-id>"],
["r", "<repo-euc>"],
["p", "<repository-owner>"],
["p", "<reviewer-npub>"],
// series structure
["t", "root"], // on FIRST patch of a series only
["t", "root-revision"], // on FIRST patch of a revision only
// optional “stable commit id” envelope for reproducibility
["commit", "<commit-id>"],
["r", "<commit-id>"], // discover patches targeting this commit
["parent-commit", "<parent-commit-id>"],
["commit-pgp-sig", "-----BEGIN PGP SIGNATURE-----..."], // "" if unsigned
["committer", "<name>", "<email>", "<timestamp>", "<tz offset minutes>"]
]
}
Chaining rules
- Each subsequent patch in a series SHOULD include NIP-10
etags linking reply → previous patch. - The first patch in a revision SHOULD reply to the original root patch.
What worked
- The stable commit envelope (commit/parent/sig/committer) lets maintainers reproduce the proposer’s commit IDs.
- Series via NIP-10 is conceptually simple and composable.
Frictions
- Atomicity: Patchsets are multi-event. Different relays → partial visibility. We added resilient graph rebuild + retry (don’t trust
created_atfor order). - Revisions:
"root-revision"helps, but you still need a DAG (root → revisions → chosen revision) to render “current” vs “superseded”. - Patchset Size: Some patchsets can be very large, as Git itself doesn’t enforce any strict limits. This can cause some
1617events to be rejected by relays based on their maximum event size limitation, which can vary from relay to relay.
Budabit practice
- Always rebuild patchsets as a graph by walking NIP-10 edges; never trust ingestion order.
- Index by both repo
aand"r": <commit-id>for “show all patches affecting commit X”. - Restrict patchset size to the smallest supported event size reported by the repo’s relays NIP-11 document.
Issues (kind 1621) + Comments (NIP-22)
Purpose (1621): Markdown issue threads; may include a subject and lightweight t tags.
{
"kind": 1621,
"content": "<markdown body>",
"tags": [
["a", "30617:<repo-owner-pubkey>:<repo-id>"],
["p", "<repository-owner>"],
["subject", "<title>"],
["t", "<issue-label>"], // lightweight tags (see NIP-32 section below)
["t", "<another-label>"]
]
}
Comments: Use NIP-22 (kind:1111), not kind-1 notes. NIP-22 introduces the upper/lower scoped tags and K/k kind markers so replies can target events, addresses, or external identities precisely.
What worked
- NIP-22 scoping (
A/E/Ifor root +a/e/ifor parent, withK/k) is robust across replaceable/addressable events. - Issues remain simple Markdown in
contentwith optionalsubject.
Frictions
- Aggregation: A full issue view is a join across 1621 (root), 1111 (comments), and 163x (status). Relays index differently, so clients must query redundantly (by
a, bye, byr:euc) and dedupe.
Budabit practice
- Subscribe on the repo address
a, then add targeted pulls by rooteandr:euc. Fold the newest valid status into the issue object (see Status below).
Status (kinds 1630–1633)
Lifecycle kinds:
- 1630 Open
- 1631 Applied/Merged (patches) or Resolved (issues)
- 1632 Closed
- 1633 Draft
{
"kind": 1631,
"content": "<markdown note>",
"tags": [
["e", "<root-issue-or-root-patch-id>", "", "root"],
["e", "<accepted-revision-root-id>", "", "reply"], // if applicable
["p", "<repository-owner>"],
["p", "<root-author>"],
["p", "<revision-author>"],
// routing & filters
["a", "30617:<repo-owner-pubkey>:<repo-id>", "<relay-url>"],
["r", "<repo-euc>"],
// details (1631)
["q", "<applied-or-merged-patch-event-id>", "<relay-url>", "<pubkey>"], // repeat per patch
["merge-commit", "<merge-commit-id>"],
["r", "<merge-commit-id>"],
["applied-as-commits", "<commit-id-in-master>", "..."],
["r", "<applied-commit-id>"] // repeat
]
}
Validation: The most recent status (created_at) from either the root author or a maintainer is valid.
Revision rule: If root is Applied/Merged (1631) and a revision isn’t named in that 1631, that revision is Closed (1632).
Budabit practice
- Treat status as an edge on the root, not a sibling stream. Join it into the primary object and show who changed it and why.
Labels via NIP-32 (the “clunky but powerful” bit)
NIP-34 happily coexists with NIP-32; in fact, NIP-34 uses simple t tags for lightweight labels, while NIP-32 provides a generalized, queryable labeling system across any target (events, people, relays, topics). Understanding NIP-32 is key to building rich, interoperable filters.
The tags and event
L- label namespace (recommended). Unambiguous names (reverse-DNS, standards like ISO).Lstarting with#means “attach a standard Nostr tag” (e.g.,["L", "#t"]says “we’re associating attag value to the target”).l- label value. If anLtag is present, thelmust include a mark equal to the namespace (third element).kind:1985- a label event that applies labels to a set of targets.
Targets: You must include at least one of e, p, a, r, or t to say what you’re labeling (events, pubkeys, addresses, relays, topics). Include relay hints for e/p (NIP-01 style).
Self-labeling: You can also add l/L directly to non-1985 events to self-label that event (e.g., language tags on a note).
Why it feels clunky (but is flexible)
-
Three moving parts: Namespace (
L), value (l), and explicit targets (e/p/a/r/t)—often with relay hints. That’s a lot of tags. -
Namespaces & marks: The
l’s third field must match anLtag to be “well-formed”, or elseugcis implied. Great for precision, awkward when quickly tagging across many events. -
Distributed joins: Labels sit in separate 1985 events or as self-labels on the target itself. Clients must search/merge both.
How NIP-34 and NIP-32 meet
- Issues (1621): may include
ttags (lightweight) and/or be the target of 1985 labels. - Patches (1617): can be labeled via 1985, or self-labeled with
l/Lif desired. - Repos (30617): can be labeled via 1985 by targeting the repo’s address
a(or evenr:eucif you need fork-group tagging).
Budabit practice (label resolution)
We compute effective labels for any object by merging, in this order:
- Self-labels on the object (
l/Lpresent on the 1621/1617/30617 event). - External label events (
kind:1985) that target the object viae/a(and, if in scope, byr:eucgroup tagging). - Legacy lightweight tags (
t) for quick filters when namespaces aren’t necessary.
We expose both raw namespaces and a normalized view (“status/open”, “type/bug”) so power users can query by L while casual users click simple chips.
Example: label an issue (by address) with a namespaced status
{
"kind": 1985,
"tags": [
["L", "com.budabit.status"], // namespace
["l", "open", "com.budabit.status"], // value (marked with the namespace)
["a", "30617:<repo-owner-pubkey>:<repo-id>"],
["e", "<issue-root-event-id>", "<relay-url>"]
]
}
Example: label a patch with a standard nostr t (via #t)
{
"kind": 1985,
"tags": [
["L", "#t"], // says “we’re attaching a `t` value”
["l", "needs-review", "#t"], // a t-like label
["e", "<patch-root-id>", "<relay-url>"]
]
}
Example: self-label language on an issue
{
"kind": 1621,
"content": "Hola mundo",
"tags": [
["a", "30617:<repo-owner-pubkey>:<repo-id>"],
["subject", "Mensaje"],
["L", "ISO-639-1"],
["l", "es", "ISO-639-1"]
]
}
Querying labels (what we actually subscribe to)
- For namespaced queries: pull 1985 by
L=<namespace>and target (e,a, etc.), plus the target’s own event to combine. - For lightweight filters: also watch
ton 1621/1617 (fast, UI-friendly), but don’t assume relays indext.
Cross-cutting lessons
- Build a tiny graph engine. Patch series/revisions (NIP-10), comments (NIP-22), status (163x), and labels (1985) are all separate streams. Rebuild a DAG per root; render from that.
- Colocate by
r:euc. It’s the best cross-host fingerprint we have (fork/mirror grouping, patch targeting). - Redundant subscriptions. Different relays index differently. We fan out by repo address
a, roote, andr:euc, then dedupe. - Labels: embrace namespaces. Use NIP-32 for anything that needs to be queried reliably. Keep
tfor quick UX filters. - Authority is policy. NIP-34 leaves auth open. We combine 30617
maintainers+ local policy to decide whose 30618 and 163x we treat as authoritative; we still display provenance.
Low-impact improvements we’d love (without changing NIP-34 semantics)
- Patchset summary envelope (tiny optional event listing ids + order) to ease multi-relay atomicity.
- 30618 diff mode (changed refs only) to reduce bandwidth.
- Common label namespaces for repo work (e.g.,
status/,type/,area/), published as a community doc so clients converge. - Soft authority hint tag schema (“set by recognized maintainer of
<repo>”) to reduce per-client heuristics.
Nostr-Git
To keep development fast while still maintaining fork compatibility, we rolled most of the Git-specific functionality into it’s own package. In addition to the NIP-32 support, this package contains several useful components such as a Svelte library for displaying NIP-32 event kinds in various ways, an Isomorphic-Git wrapper for in browser Git support and functions for manipulating various event kinds. We aim to make Nostr-Git available separately from Flotilla-Budabit through your favorite package manager.
Final Take
NIP-34 + NIP-22 + NIP-32 are a clean, composable substrate. The flip side of that minimalism is client-side assembly and policy - but once we leaned in (group by r:euc, rebuild graphs from e markers, fold status, merge labels by namespace), Budabit’s UX became more predictable.
If you’re implementing this: start with grouping and the graph utilities; add a label resolver that understands L/l and t; make your subscriptions redundant and your joins idempotent. The rest will click.