Lessons from Implementing NIP-34 in Flotilla-Budabit

At the Flotilla-Budabit project we’re building a Nostr-native Git-centric collaboration client based on Flotilla. NIP-34 is the backbone: it defines how repositories are announced, how repository state is broadcast, how patches and issues are represented, and how status is tracked - using plain Nostr events. NIP-22 supplies the comment threading model, and NIP-32 defines a general labeling system we can apply across those objects. After multiple iterations, we've done some reflecting on what worked, what didn’t, and the exact event shapes and cross-NIP interactions we implemented (and wrestled with) and we're sharing it here with you.
Lessons from Implementing NIP-34 in Flotilla-Budabit

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

  • relays is a clear rendezvous point for collaboration traffic.
  • r with 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; treat d as local handle(s) inside the group.
  • Under one “repo”, show all web/clone facets and the union of maintainers.

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 refs tags 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 e tags 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_at for 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 1617 events 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 a and "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/I for root + a/e/i for parent, with K/k) is robust across replaceable/addressable events.
  • Issues remain simple Markdown in content with optional subject.

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, by e, by r:euc) and dedupe.

Budabit practice

  • Subscribe on the repo address a, then add targeted pulls by root e and r: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). L starting with # means “attach a standard Nostr tag” (e.g., ["L", "#t"] says “we’re associating a t tag value to the target”).
  • l - label value. If an L tag is present, the l must 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 an L tag to be “well-formed”, or else ugc is 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 t tags (lightweight) and/or be the target of 1985 labels.
  • Patches (1617): can be labeled via 1985, or self-labeled with l/L if desired.
  • Repos (30617): can be labeled via 1985 by targeting the repo’s address a (or even r:euc if you need fork-group tagging).

Budabit practice (label resolution)

We compute effective labels for any object by merging, in this order:

  1. Self-labels on the object (l/L present on the 1621/1617/30617 event).
  2. External label events (kind:1985) that target the object via e/a (and, if in scope, by r:euc group tagging).
  3. 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 t on 1621/1617 (fast, UI-friendly), but don’t assume relays index t.

Cross-cutting lessons

  1. 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.
  2. Colocate by r:euc. It’s the best cross-host fingerprint we have (fork/mirror grouping, patch targeting).
  3. Redundant subscriptions. Different relays index differently. We fan out by repo address a, root e, and r:euc, then dedupe.
  4. Labels: embrace namespaces. Use NIP-32 for anything that needs to be queried reliably. Keep t for quick UX filters.
  5. 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.

Links


No comments yet.