feat(nip34): consolidate duplicate role-tag histories instead of rejecting

The NIP allows a pubkey one tag of each role letter; a second tag under
the same letter is out of spec. Rejecting such announcements dropped
otherwise-valid membership data over a formatting slip, and clients that
merge histories tag-by-tag can plausibly emit them. Tolerate them
instead: histories for a pubkey are consolidated, and since this service
only evaluates current activity - never time-scoped historic authority -
consolidation reduces to "a maintainer while any `M`/`m` entry is
active", exactly the rule already applied across letters for role
transitions.

Duplicate-rejection tests become consolidation tests: an ended entry
plus an active one keeps the pubkey a maintainer (including the author's
own self-entries), all-ended duplicates end it, and duplicate moderator
tags still grant no maintainership.

Validation: nostr::events and git::authorization unit tests and the
state_authorization suite pass.
This commit is contained in:
DanConwayDev
2026-08-19 11:29:57 +00:00
parent e9a54678d5
commit 2262adea35
3 changed files with 67 additions and 30 deletions
+4 -3
View File
@@ -291,9 +291,10 @@ Announcements carry maintainer listings in two formats:
entries are ignored entirely - the history is only used to conclude a
role has *ended*, never to grant authority for a past period. The lead /
co-maintainer distinction carries no meaning for this service. A pubkey
may appear in one `M` and one `m` tag to record a role transition and is
a maintainer while either entry is active; a second tag under the same
letter rejects the announcement. An author who appears in no role tag is
may appear in multiple role tags: one of each letter records a role
transition, and out-of-spec duplicates under the same letter are
tolerated. Histories are consolidated - the pubkey is a maintainer while
any `M`/`m` entry is active. An author who appears in no role tag is
implicitly a maintainer for the repository's entire history.
- **Deprecated `maintainers` tag** (fallback). Ignored when `M`/`m` tags
are present. Without any listing tags the author is the sole maintainer.
+8 -5
View File
@@ -249,9 +249,12 @@ NIP-34 maintainers model refined in nips commit `781590b`).
grant time-scoped retroactive authority over historic events. The
NIP's owner-first precedence for conflicting past-role records is
therefore unused.
- A pubkey may appear in one `M` and one `m` tag to record a role
transition and is a maintainer while either entry is active. A second
tag under the same letter is malformed and rejects the announcement.
- A pubkey may appear in multiple role tags: one of each letter records a
role transition per the NIP, and out-of-spec duplicates under the same
letter are consolidated rather than rejected - rejecting them would drop
otherwise-valid membership data over a formatting slip. Since only
current activity matters here, consolidation reduces to: a pubkey is a
maintainer while any of its `M`/`m` entries is active.
- An announcement using role tags acknowledges its author via an active
self-entry, or implicitly: an author who appears in no role tag is a
maintainer for the repository's entire history. Only an ended self-entry
@@ -273,8 +276,8 @@ The tag still participates in announcement parsing (per nips commit
`986edd1`): its presence suppresses the deprecated `maintainers` fallback,
and a self-`o` entry is a self-role, so a moderator-only author is not
implicitly a maintainer and their state events are not authorized. Like
`M`/`m`, a pubkey may appear in at most one `o` tag; a duplicate rejects
the announcement.
`M`/`m`, duplicate `o` tags for one pubkey are consolidated rather than
rejected.
Deliberately deferred: announcements from moderators are not walked for
the `M`/`m` assignments they might carry (the NIP says role combinations
+55 -22
View File
@@ -129,10 +129,11 @@ impl RepositoryAnnouncement {
// A role tag lists a pubkey followed by optional alternating start/end
// history timestamps; it is currently active when it has fewer than
// four elements or an odd number of elements. Ended entries are
// ignored entirely. A pubkey may appear in one tag of each letter to
// record transitions between the roles and is a maintainer while
// either its `M` or `m` entry is active; a second tag under the same
// letter is malformed and rejects the announcement.
// ignored entirely. A pubkey may appear in multiple role tags: one
// tag of each letter records transitions between roles, and
// out-of-spec duplicates under the same letter are tolerated. The
// histories are consolidated - the pubkey is a maintainer while any
// of its `M`/`m` entries is active.
//
// The moderator role (`o`) grants no maintainership: moderators are
// only empowered to have status events (kinds 1630-1633) treated as
@@ -147,7 +148,6 @@ impl RepositoryAnnouncement {
let mut role_tags_present = false;
let mut author_has_role_entry = false;
let mut role_active: Vec<String> = Vec::new();
let mut seen_role_entries: Vec<(String, String)> = Vec::new();
for tag in event.tags.iter() {
let slice = tag.as_slice();
let Some(kind) = slice.first() else { continue };
@@ -158,11 +158,6 @@ impl RepositoryAnnouncement {
let Some(pubkey) = slice.get(1).filter(|value| !value.is_empty()) else {
continue;
};
let entry = (kind.clone(), pubkey.clone());
if seen_role_entries.contains(&entry) {
return Err(anyhow!("duplicate `{kind}` role tag for pubkey {pubkey}"));
}
seen_role_entries.push(entry);
if *pubkey == author_hex {
author_has_role_entry = true;
}
@@ -1128,12 +1123,14 @@ mod tests {
}
#[test]
fn test_duplicate_moderator_tag_rejects_announcement() {
fn test_duplicate_moderator_tags_are_consolidated() {
use nostr_sdk::prelude::Tag;
let keys = create_test_keys();
let moderator = create_test_keys().public_key().to_hex();
// Out-of-spec duplicate `o` tags do not reject the announcement, and
// moderator entries still grant no maintainership.
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
.tags(vec![
Tag::custom("d", vec!["test-repo".to_string()]),
@@ -1141,37 +1138,73 @@ mod tests {
"o",
vec![moderator.clone(), "0".to_string(), "100".to_string()],
),
Tag::custom("o", vec![moderator]),
Tag::custom("o", vec![moderator.clone()]),
])
.finalize(&keys)
.unwrap();
let result = RepositoryAnnouncement::from_event(event);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("duplicate"));
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
assert!(!announcement.listed_maintainers().contains(&moderator));
}
#[test]
fn test_duplicate_same_letter_role_tag_rejects_announcement() {
fn test_duplicate_same_letter_role_tags_are_consolidated() {
use nostr_sdk::prelude::Tag;
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let other = create_test_keys().public_key().to_hex();
let returned = create_test_keys().public_key().to_hex();
let gone = create_test_keys().public_key().to_hex();
// Out-of-spec duplicate tags under the same letter are consolidated
// instead of rejecting the announcement: a pubkey is a maintainer
// while any of its entries is active.
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
.tags(vec![
Tag::custom("d", vec!["test-repo".to_string()]),
Tag::custom("M", vec![author]),
Tag::custom("m", vec![other.clone(), "0".to_string(), "100".to_string()]),
Tag::custom("m", vec![other]),
Tag::custom(
"m",
vec![returned.clone(), "0".to_string(), "100".to_string()],
),
Tag::custom("m", vec![returned.clone(), "200".to_string()]),
Tag::custom("m", vec![gone.clone(), "0".to_string(), "100".to_string()]),
Tag::custom(
"m",
vec![gone.clone(), "200".to_string(), "300".to_string()],
),
])
.finalize(&keys)
.unwrap();
let result = RepositoryAnnouncement::from_event(event);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("duplicate"));
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
let listed = announcement.listed_maintainers();
assert!(listed.contains(&returned));
assert!(!listed.contains(&gone));
}
#[test]
fn test_author_duplicate_entries_consolidate_to_active() {
use nostr_sdk::prelude::Tag;
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
.tags(vec![
Tag::custom("d", vec!["test-repo".to_string()]),
Tag::custom(
"m",
vec![author.clone(), "0".to_string(), "100".to_string()],
),
Tag::custom("m", vec![author]),
])
.finalize(&keys)
.unwrap();
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
assert!(announcement.author_role_active());
assert!(!announcement.author_has_left());
}
#[test]