Converge remaining gossiped state: node-local news/reports/help and deterministic group registration
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
Jean Chevronnet 2026-07-19 14:11:50 +00:00
parent fef4590f48
commit e23240bf52
No known key found for this signature in database
4 changed files with 51 additions and 16 deletions

View file

@ -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<String, Account>, 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 } => {

View file

@ -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<GroupMember>,
}
@ -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() });
}

View file

@ -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(())
}

View file

@ -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.