From e23240bf524ce70c5a87bf8d8a0ae16b3ad99ee4 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 14:11:50 +0000 Subject: [PATCH] Converge remaining gossiped state: node-local news/reports/help and deterministic group registration --- src/engine/db/event.rs | 42 +++++++++++++++++++++++++++------------- src/engine/db/mod.rs | 3 ++- src/engine/db/network.rs | 5 +++-- src/engine/db/tests.rs | 17 ++++++++++++++++ 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index d4bbb83..4500b90 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -93,7 +93,7 @@ pub enum Event { ReportClosed { id: u64 }, ReportDeleted { id: u64 }, // User groups (GroupServ). Global — groups are account identity. - GroupRegistered { name: String, founder: String, ts: u64 }, + GroupRegistered { name: String, founder: String, ts: u64, #[serde(default)] home: String }, GroupDropped { name: String }, GroupFounderSet { name: String, founder: String }, // Upsert a member (empty flags = a plain member, still present). @@ -221,19 +221,11 @@ impl Event { | Event::ForbidRemoved { .. } | Event::NotifyAdded { .. } | Event::NotifyRemoved { .. } - | Event::NewsAdded { .. } - | Event::NewsDeleted { .. } - | Event::ReportFiled { .. } - | Event::ReportClosed { .. } - | Event::ReportDeleted { .. } | Event::GroupRegistered { .. } | Event::GroupDropped { .. } | Event::GroupFounderSet { .. } | Event::GroupFlagsSet { .. } | Event::GroupMemberDel { .. } - | Event::HelpRequested { .. } - | Event::HelpTaken { .. } - | Event::HelpClosed { .. } | Event::OperGranted { .. } | Event::OperRevoked { .. } | Event::SessionExceptionAdded { .. } @@ -277,7 +269,19 @@ impl Event { | Event::JupeAdded { .. } | Event::JupeRemoved { .. } | Event::StatsSet { .. } - | Event::IncidentsSet { .. } => Scope::Local, + | Event::IncidentsSet { .. } + // News and the moderation queues (reports, help tickets) are node-local + // operational state: their ids are per-node counters, so gossiping them + // would collide id namespaces across nodes and never converge. Keep them + // local (like channels) — each node owns its own. + | Event::NewsAdded { .. } + | Event::NewsDeleted { .. } + | Event::ReportFiled { .. } + | Event::ReportClosed { .. } + | Event::ReportDeleted { .. } + | Event::HelpRequested { .. } + | Event::HelpTaken { .. } + | Event::HelpClosed { .. } => Scope::Local, } } } @@ -745,10 +749,22 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash Event::ReportDeleted { id } => { net.reports.retain(|r| r.id != id); } - Event::GroupRegistered { name, founder, ts } => { + Event::GroupRegistered { name, founder, ts, home } => { let k = key(&name); - if !net.groups.iter().any(|g| key(&g.name) == k) { - net.groups.push(Group { name, founder, ts, members: Vec::new() }); + let claim = Group { name, founder, ts, home, members: Vec::new() }; + match net.groups.iter().position(|g| key(&g.name) == k) { + None => net.groups.push(claim), + // Same name claimed on two nodes: keep the earlier registration + // (ts, then origin) deterministically so every node converges — + // mirrors AccountRegistered / owns_over. Single-node replay never + // hits this (one GroupRegistered per live name). + Some(i) => { + let held = &net.groups[i]; + let keep_existing = (held.ts, held.home.as_str()) < (claim.ts, claim.home.as_str()); + if !keep_existing { + net.groups[i] = claim; + } + } } } Event::GroupDropped { name } => { diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 1ccf66d..0f09260 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -291,6 +291,7 @@ pub struct Group { pub name: String, // "!name", casefolded pub founder: String, pub ts: u64, + pub home: String, // origin node that registered it, for deterministic conflict resolution pub members: Vec, } @@ -1342,7 +1343,7 @@ impl Db { } } for g in &self.net.groups { - snapshot.push(Event::GroupRegistered { name: g.name.clone(), founder: g.founder.clone(), ts: g.ts }); + snapshot.push(Event::GroupRegistered { name: g.name.clone(), founder: g.founder.clone(), ts: g.ts, home: g.home.clone() }); for m in &g.members { snapshot.push(Event::GroupFlagsSet { name: g.name.clone(), account: m.account.clone(), flags: m.flags.clone() }); } diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 96b252f..fe3725d 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -586,8 +586,9 @@ impl Db { return Err(ChanError::Exists); } let ts = now(); - self.log.append(Event::GroupRegistered { name: name.to_string(), founder: founder.to_string(), ts }).map_err(|_| ChanError::Internal)?; - self.net.groups.push(Group { name: name.to_string(), founder: founder.to_string(), ts, members: Vec::new() }); + let home = self.log.origin.clone(); + self.log.append(Event::GroupRegistered { name: name.to_string(), founder: founder.to_string(), ts, home: home.clone() }).map_err(|_| ChanError::Internal)?; + self.net.groups.push(Group { name: name.to_string(), founder: founder.to_string(), ts, home, members: Vec::new() }); Ok(()) } diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index c9ac59c..d8639e5 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -54,6 +54,23 @@ assert_eq!(converge(&a, &a), "A"); } + #[test] + fn group_registration_conflict_converges_deterministically() { + let reg = |founder: &str, ts: u64, home: &str| Event::GroupRegistered { name: "!g".into(), founder: founder.into(), ts, home: home.into() }; + let converge = |first: Event, second: Event| { + let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default()); + apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, first); + apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, second); + nd.groups[0].founder.clone() + }; + let a = reg("alice", 100, "nodeA"); + let b = reg("bob", 100, "nodeB"); + // Two nodes claim "!g": the earlier (ts, then origin) wins, same result in + // either apply order — so gossip peers converge instead of diverging. + assert_eq!(converge(a.clone(), b.clone()), "alice", "(100,nodeA) beats (100,nodeB)"); + assert_eq!(converge(b, a), "alice", "commutative: converges to the same founder"); + } + // A gossiped registration that wins the name (earlier ts) moves an account's // home. That is a hostile takeover — logging the local user out — ONLY if the // credential differs; re-asserting the SAME account (same verifier) must not.