use crate::groups::member::{GroupMember, GroupMemberState, GroupRole};
use crate::groups::policy::GroupPolicy;
use crate::identity::AgentKeypair;
use ant_quic::crypto::raw_public_keys::pqc::{
sign_with_ml_dsa, verify_with_ml_dsa, MlDsaSignature,
};
use ant_quic::MlDsaPublicKey;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const STATE_COMMIT_DOMAIN: &[u8] = b"x0x.group.state-commit.v1";
pub const CARD_SIGNATURE_DOMAIN: &[u8] = b"x0x.group.card.v1";
pub const EVENT_SIGNATURE_DOMAIN: &[u8] = b"x0x.group.event.v1";
pub const DEFAULT_CARD_TTL_SECS: u64 = 24 * 60 * 60;
fn blake3_hex(input: &[u8]) -> String {
hex::encode(blake3::hash(input).as_bytes())
}
#[must_use]
pub fn compute_roster_root(members_v2: &BTreeMap<String, GroupMember>) -> String {
let mut entries: Vec<(&String, &GroupMember)> = members_v2
.iter()
.filter(|(_, m)| matches!(m.state, GroupMemberState::Active | GroupMemberState::Banned))
.collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut buf = Vec::with_capacity(entries.len() * 48 + 16);
buf.extend_from_slice(b"x0x.roster-root.v1");
for (id, m) in entries {
buf.push(b'|');
buf.extend_from_slice(id.as_bytes());
buf.push(b':');
buf.push(role_byte(m.role));
buf.push(state_byte(m.state));
}
blake3_hex(&buf)
}
#[must_use]
pub fn compute_policy_hash(policy: &GroupPolicy) -> String {
let bytes = bincode::serialize(policy).unwrap_or_default();
let mut buf = Vec::with_capacity(bytes.len() + 32);
buf.extend_from_slice(b"x0x.policy-hash.v1");
buf.extend_from_slice(&bytes);
blake3_hex(&buf)
}
#[must_use]
pub fn compute_public_meta_hash(meta: &GroupPublicMeta) -> String {
let mut buf = Vec::with_capacity(256);
buf.extend_from_slice(b"x0x.public-meta.v1");
push_len_prefixed(&mut buf, meta.name.as_bytes());
push_len_prefixed(&mut buf, meta.description.as_bytes());
let mut tags_sorted = meta.tags.clone();
tags_sorted.sort();
tags_sorted.dedup();
buf.extend_from_slice(&(tags_sorted.len() as u32).to_le_bytes());
for tag in &tags_sorted {
push_len_prefixed(&mut buf, tag.as_bytes());
}
push_len_prefixed(
&mut buf,
meta.avatar_url.as_deref().unwrap_or("").as_bytes(),
);
push_len_prefixed(
&mut buf,
meta.banner_url.as_deref().unwrap_or("").as_bytes(),
);
blake3_hex(&buf)
}
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn compute_state_hash(
group_id: &str,
revision: u64,
prev_state_hash: Option<&str>,
roster_root: &str,
policy_hash: &str,
public_meta_hash: &str,
security_binding: Option<&str>,
withdrawn: bool,
) -> String {
let mut buf = Vec::with_capacity(512);
buf.extend_from_slice(STATE_COMMIT_DOMAIN);
push_len_prefixed(&mut buf, group_id.as_bytes());
buf.extend_from_slice(&revision.to_le_bytes());
push_len_prefixed(&mut buf, prev_state_hash.unwrap_or("").as_bytes());
push_len_prefixed(&mut buf, roster_root.as_bytes());
push_len_prefixed(&mut buf, policy_hash.as_bytes());
push_len_prefixed(&mut buf, public_meta_hash.as_bytes());
push_len_prefixed(&mut buf, security_binding.unwrap_or("").as_bytes());
buf.push(if withdrawn { 1 } else { 0 });
blake3_hex(&buf)
}
fn push_len_prefixed(buf: &mut Vec<u8>, bytes: &[u8]) {
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(bytes);
}
fn role_byte(role: GroupRole) -> u8 {
match role {
GroupRole::Owner => 4,
GroupRole::Admin => 3,
GroupRole::Moderator => 2,
GroupRole::Member => 1,
GroupRole::Guest => 0,
}
}
fn state_byte(state: GroupMemberState) -> u8 {
match state {
GroupMemberState::Active => 1,
GroupMemberState::Banned => 2,
GroupMemberState::Removed => 3,
GroupMemberState::Pending => 4,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroupGenesis {
pub group_id: String,
pub creator_agent_id: String,
pub created_at: u64,
pub creation_nonce: String,
}
impl GroupGenesis {
#[must_use]
pub fn new(creator_agent_id: String, created_at: u64) -> Self {
let nonce = {
use rand::RngCore;
let mut n = [0u8; 32];
rand::thread_rng().fill_bytes(&mut n);
hex::encode(n)
};
let group_id = Self::derive_group_id(&creator_agent_id, created_at, &nonce);
Self {
group_id,
creator_agent_id,
created_at,
creation_nonce: nonce,
}
}
#[must_use]
pub fn with_existing_id(
group_id: String,
creator_agent_id: String,
created_at: u64,
creation_nonce: String,
) -> Self {
Self {
group_id,
creator_agent_id,
created_at,
creation_nonce,
}
}
#[must_use]
pub fn derive_group_id(creator_agent_id: &str, created_at: u64, nonce: &str) -> String {
let mut buf = Vec::with_capacity(128);
buf.extend_from_slice(b"x0x.group.genesis.v1");
push_len_prefixed(&mut buf, creator_agent_id.as_bytes());
buf.extend_from_slice(&created_at.to_le_bytes());
push_len_prefixed(&mut buf, nonce.as_bytes());
blake3_hex(&buf)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroupPublicMeta {
pub name: String,
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub avatar_url: Option<String>,
#[serde(default)]
pub banner_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroupStateCommit {
pub group_id: String,
pub revision: u64,
#[serde(default)]
pub prev_state_hash: Option<String>,
pub roster_root: String,
pub policy_hash: String,
pub public_meta_hash: String,
#[serde(default)]
pub security_binding: Option<String>,
pub state_hash: String,
#[serde(default)]
pub withdrawn: bool,
pub committed_by: String,
pub committed_at: u64,
pub signer_public_key: String,
pub signature: String,
}
impl GroupStateCommit {
#[must_use]
pub fn signable_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(512);
buf.extend_from_slice(STATE_COMMIT_DOMAIN);
push_len_prefixed(&mut buf, self.group_id.as_bytes());
buf.extend_from_slice(&self.revision.to_le_bytes());
push_len_prefixed(
&mut buf,
self.prev_state_hash.as_deref().unwrap_or("").as_bytes(),
);
push_len_prefixed(&mut buf, self.roster_root.as_bytes());
push_len_prefixed(&mut buf, self.policy_hash.as_bytes());
push_len_prefixed(&mut buf, self.public_meta_hash.as_bytes());
push_len_prefixed(
&mut buf,
self.security_binding.as_deref().unwrap_or("").as_bytes(),
);
buf.push(if self.withdrawn { 1 } else { 0 });
push_len_prefixed(&mut buf, self.state_hash.as_bytes());
push_len_prefixed(&mut buf, self.committed_by.as_bytes());
buf.extend_from_slice(&self.committed_at.to_le_bytes());
buf
}
#[allow(clippy::too_many_arguments)]
pub fn sign(
group_id: String,
revision: u64,
prev_state_hash: Option<String>,
roster_root: String,
policy_hash: String,
public_meta_hash: String,
security_binding: Option<String>,
withdrawn: bool,
committed_at: u64,
keypair: &AgentKeypair,
) -> Result<Self, ApplyError> {
let committed_by = hex::encode(keypair.agent_id().as_bytes());
let signer_public_key = hex::encode(keypair.public_key().as_bytes());
let state_hash = compute_state_hash(
&group_id,
revision,
prev_state_hash.as_deref(),
&roster_root,
&policy_hash,
&public_meta_hash,
security_binding.as_deref(),
withdrawn,
);
let mut commit = Self {
group_id,
revision,
prev_state_hash,
roster_root,
policy_hash,
public_meta_hash,
security_binding,
state_hash,
withdrawn,
committed_by,
committed_at,
signer_public_key,
signature: String::new(),
};
let sig = sign_with_ml_dsa(keypair.secret_key(), &commit.signable_bytes())
.map_err(|e| ApplyError::InvalidSignature(format!("{e:?}")))?;
commit.signature = hex::encode(sig.as_bytes());
Ok(commit)
}
pub fn verify_structure(&self) -> Result<(), ApplyError> {
let expected = compute_state_hash(
&self.group_id,
self.revision,
self.prev_state_hash.as_deref(),
&self.roster_root,
&self.policy_hash,
&self.public_meta_hash,
self.security_binding.as_deref(),
self.withdrawn,
);
if expected != self.state_hash {
return Err(ApplyError::StateHashMismatch {
expected,
got: self.state_hash.clone(),
});
}
let pubkey_bytes = hex::decode(&self.signer_public_key)
.map_err(|e| ApplyError::InvalidSignature(format!("bad pubkey hex: {e}")))?;
let pubkey = MlDsaPublicKey::from_bytes(&pubkey_bytes)
.map_err(|e| ApplyError::InvalidSignature(format!("bad pubkey: {e:?}")))?;
let derived = hex::encode(ant_quic::derive_peer_id_from_public_key(&pubkey).0);
if derived != self.committed_by {
return Err(ApplyError::InvalidSignature(format!(
"committed_by {} does not match signer_public_key-derived {}",
self.committed_by, derived
)));
}
let sig_bytes = hex::decode(&self.signature)
.map_err(|e| ApplyError::InvalidSignature(format!("bad sig hex: {e}")))?;
let sig = MlDsaSignature::from_bytes(&sig_bytes)
.map_err(|e| ApplyError::InvalidSignature(format!("bad sig: {e:?}")))?;
verify_with_ml_dsa(&pubkey, &self.signable_bytes(), &sig)
.map_err(|e| ApplyError::InvalidSignature(format!("verify failed: {e:?}")))?;
Ok(())
}
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum ApplyError {
#[error("stale revision: got {got}, have {have}")]
StaleRevision { got: u64, have: u64 },
#[error("prev_state_hash mismatch: expected {expected:?}, got {got:?}")]
PrevHashMismatch {
expected: Option<String>,
got: Option<String>,
},
#[error("unauthorised signer {signer} for action {action}")]
Unauthorized {
signer: String,
action: &'static str,
},
#[error("invalid signature: {0}")]
InvalidSignature(String),
#[error("invariant violation: {0}")]
Invariant(String),
#[error("group is withdrawn")]
Withdrawn,
#[error("missing target: {0}")]
MissingTarget(String),
#[error("state hash mismatch: expected {expected}, got {got}")]
StateHashMismatch { expected: String, got: String },
#[error("group_id mismatch: got {got}, expected {expected}")]
GroupIdMismatch { expected: String, got: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionKind {
OwnerOnly,
AdminOrHigher,
MemberSelf,
NonMemberRequest,
}
impl ActionKind {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::OwnerOnly => "owner-only",
Self::AdminOrHigher => "admin-or-higher",
Self::MemberSelf => "member-self",
Self::NonMemberRequest => "non-member-request",
}
}
}
#[derive(Debug, Clone)]
pub struct ApplyContext<'a> {
pub current_state_hash: &'a str,
pub current_revision: u64,
pub current_withdrawn: bool,
pub members_v2: &'a BTreeMap<String, GroupMember>,
pub group_id: &'a str,
}
pub fn validate_apply(
ctx: &ApplyContext<'_>,
commit: &GroupStateCommit,
action_kind: ActionKind,
) -> Result<(), ApplyError> {
if commit.group_id != ctx.group_id {
return Err(ApplyError::GroupIdMismatch {
expected: ctx.group_id.to_string(),
got: commit.group_id.clone(),
});
}
commit.verify_structure()?;
if commit.revision <= ctx.current_revision {
return Err(ApplyError::StaleRevision {
got: commit.revision,
have: ctx.current_revision,
});
}
let expected_prev = Some(ctx.current_state_hash.to_string());
if commit.prev_state_hash != expected_prev {
return Err(ApplyError::PrevHashMismatch {
expected: expected_prev,
got: commit.prev_state_hash.clone(),
});
}
if ctx.current_withdrawn && !commit.withdrawn {
return Err(ApplyError::Withdrawn);
}
let signer_role = ctx
.members_v2
.get(&commit.committed_by)
.filter(|m| m.is_active())
.map(|m| m.role);
let authorized = match action_kind {
ActionKind::OwnerOnly => signer_role == Some(GroupRole::Owner),
ActionKind::AdminOrHigher => signer_role
.map(|r| r.at_least(GroupRole::Admin))
.unwrap_or(false),
ActionKind::MemberSelf => signer_role.is_some(),
ActionKind::NonMemberRequest => signer_role.is_none(),
};
if !authorized {
return Err(ApplyError::Unauthorized {
signer: commit.committed_by.clone(),
action: action_kind.name(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::groups::member::GroupMember;
use crate::groups::policy::GroupPolicyPreset;
fn make_owner(hex_id: &str) -> GroupMember {
GroupMember::new_owner(hex_id.to_string(), Some("Owner".into()), 1_000)
}
fn make_member(hex_id: &str, role: GroupRole) -> GroupMember {
let mut m = GroupMember::new_member(hex_id.to_string(), None, None, 2_000);
m.role = role;
m
}
fn make_banned(hex_id: &str) -> GroupMember {
let mut m = make_member(hex_id, GroupRole::Member);
m.state = GroupMemberState::Banned;
m
}
fn make_removed(hex_id: &str) -> GroupMember {
let mut m = make_member(hex_id, GroupRole::Member);
m.state = GroupMemberState::Removed;
m
}
#[test]
fn group_id_is_stable_across_renames() {
let g = GroupGenesis::new("aa".repeat(32), 1_000);
let reconstructed =
GroupGenesis::derive_group_id(&g.creator_agent_id, g.created_at, &g.creation_nonce);
assert_eq!(g.group_id, reconstructed);
let g2 = GroupGenesis::new("aa".repeat(32), 1_000);
assert_ne!(g.group_id, g2.group_id);
}
#[test]
fn roster_root_excludes_removed_and_pending() {
let mut m = BTreeMap::new();
m.insert("aa".repeat(32), make_owner(&"aa".repeat(32)));
m.insert(
"bb".repeat(32),
make_member(&"bb".repeat(32), GroupRole::Member),
);
m.insert("cc".repeat(32), make_removed(&"cc".repeat(32)));
let mut p = BTreeMap::new();
p.insert("dd".repeat(32), {
let mut x = make_member(&"dd".repeat(32), GroupRole::Member);
x.state = GroupMemberState::Pending;
x
});
p.insert("aa".repeat(32), make_owner(&"aa".repeat(32)));
p.insert(
"bb".repeat(32),
make_member(&"bb".repeat(32), GroupRole::Member),
);
assert_eq!(compute_roster_root(&m), compute_roster_root(&p));
}
#[test]
fn roster_root_covers_ban_state() {
let mut active = BTreeMap::new();
active.insert("aa".repeat(32), make_owner(&"aa".repeat(32)));
active.insert(
"bb".repeat(32),
make_member(&"bb".repeat(32), GroupRole::Member),
);
let mut banned = active.clone();
banned.insert("bb".repeat(32), make_banned(&"bb".repeat(32)));
assert_ne!(compute_roster_root(&active), compute_roster_root(&banned));
}
#[test]
fn roster_root_covers_role_change() {
let mut a = BTreeMap::new();
a.insert("aa".repeat(32), make_owner(&"aa".repeat(32)));
a.insert(
"bb".repeat(32),
make_member(&"bb".repeat(32), GroupRole::Member),
);
let mut b = a.clone();
b.insert(
"bb".repeat(32),
make_member(&"bb".repeat(32), GroupRole::Admin),
);
assert_ne!(compute_roster_root(&a), compute_roster_root(&b));
}
#[test]
fn policy_hash_changes_with_policy() {
let p1 = GroupPolicyPreset::PrivateSecure.to_policy();
let p2 = GroupPolicyPreset::PublicRequestSecure.to_policy();
assert_ne!(compute_policy_hash(&p1), compute_policy_hash(&p2));
}
#[test]
fn public_meta_hash_stable_across_tag_reorder() {
let a = GroupPublicMeta {
name: "N".into(),
description: "D".into(),
tags: vec!["ai".into(), "rust".into()],
avatar_url: None,
banner_url: None,
};
let b = GroupPublicMeta {
name: "N".into(),
description: "D".into(),
tags: vec!["rust".into(), "ai".into()],
avatar_url: None,
banner_url: None,
};
assert_eq!(compute_public_meta_hash(&a), compute_public_meta_hash(&b));
}
#[test]
fn public_meta_hash_dedups_tags() {
let a = GroupPublicMeta {
name: "N".into(),
description: "".into(),
tags: vec!["ai".into()],
..Default::default()
};
let b = GroupPublicMeta {
name: "N".into(),
description: "".into(),
tags: vec!["ai".into(), "ai".into()],
..Default::default()
};
assert_eq!(compute_public_meta_hash(&a), compute_public_meta_hash(&b));
}
#[test]
fn state_hash_deterministic() {
let h1 = compute_state_hash(
"g1",
1,
Some("prev"),
"roster",
"policy",
"meta",
Some("epoch:3"),
false,
);
let h2 = compute_state_hash(
"g1",
1,
Some("prev"),
"roster",
"policy",
"meta",
Some("epoch:3"),
false,
);
assert_eq!(h1, h2);
}
#[test]
fn state_hash_sensitive_to_every_component() {
let base = || {
compute_state_hash(
"g1",
1,
Some("prev"),
"roster",
"policy",
"meta",
Some("epoch:3"),
false,
)
};
assert_ne!(
base(),
compute_state_hash(
"g2",
1,
Some("prev"),
"roster",
"policy",
"meta",
Some("epoch:3"),
false
)
);
assert_ne!(
base(),
compute_state_hash(
"g1",
2,
Some("prev"),
"roster",
"policy",
"meta",
Some("epoch:3"),
false
)
);
assert_ne!(
base(),
compute_state_hash(
"g1",
1,
Some("other"),
"roster",
"policy",
"meta",
Some("epoch:3"),
false
)
);
assert_ne!(
base(),
compute_state_hash(
"g1",
1,
Some("prev"),
"XX",
"policy",
"meta",
Some("epoch:3"),
false
)
);
assert_ne!(
base(),
compute_state_hash(
"g1",
1,
Some("prev"),
"roster",
"XX",
"meta",
Some("epoch:3"),
false
)
);
assert_ne!(
base(),
compute_state_hash(
"g1",
1,
Some("prev"),
"roster",
"policy",
"XX",
Some("epoch:3"),
false
)
);
assert_ne!(
base(),
compute_state_hash(
"g1",
1,
Some("prev"),
"roster",
"policy",
"meta",
Some("epoch:4"),
false
)
);
assert_ne!(
base(),
compute_state_hash(
"g1",
1,
Some("prev"),
"roster",
"policy",
"meta",
Some("epoch:3"),
true
)
);
}
#[test]
fn commit_sign_and_verify_roundtrip() {
let kp = AgentKeypair::generate().unwrap();
let commit = GroupStateCommit::sign(
"g1".into(),
1,
None,
"roster".into(),
"policy".into(),
"meta".into(),
Some("epoch:0".into()),
false,
12_345,
&kp,
)
.unwrap();
commit.verify_structure().unwrap();
}
#[test]
fn commit_signature_tampering_detected() {
let kp = AgentKeypair::generate().unwrap();
let mut commit = GroupStateCommit::sign(
"g1".into(),
1,
None,
"roster".into(),
"policy".into(),
"meta".into(),
None,
false,
12_345,
&kp,
)
.unwrap();
commit.state_hash = "deadbeef".into();
assert!(matches!(
commit.verify_structure(),
Err(ApplyError::StateHashMismatch { .. })
));
}
#[test]
fn commit_committed_by_must_match_pubkey() {
let kp = AgentKeypair::generate().unwrap();
let mut commit = GroupStateCommit::sign(
"g1".into(),
1,
None,
"roster".into(),
"policy".into(),
"meta".into(),
None,
false,
12_345,
&kp,
)
.unwrap();
commit.committed_by = "aa".repeat(32);
assert!(matches!(
commit.verify_structure(),
Err(ApplyError::InvalidSignature(_))
));
}
#[test]
fn validate_apply_rejects_stale_revision() {
let owner_hex = "aa".repeat(32);
let kp = AgentKeypair::generate().unwrap();
let signer_hex = hex::encode(kp.agent_id().as_bytes());
let mut members = BTreeMap::new();
members.insert(signer_hex.clone(), make_owner(&signer_hex));
let commit = GroupStateCommit::sign(
"g1".into(),
1, Some("old".into()),
"r".into(),
"p".into(),
"m".into(),
None,
false,
0,
&kp,
)
.unwrap();
let ctx = ApplyContext {
current_state_hash: "current",
current_revision: 1,
current_withdrawn: false,
members_v2: &members,
group_id: "g1",
};
let err = validate_apply(&ctx, &commit, ActionKind::OwnerOnly).unwrap_err();
assert!(matches!(err, ApplyError::StaleRevision { got: 1, have: 1 }));
let _ = owner_hex; }
#[test]
fn validate_apply_rejects_prev_hash_break() {
let kp = AgentKeypair::generate().unwrap();
let signer_hex = hex::encode(kp.agent_id().as_bytes());
let mut members = BTreeMap::new();
members.insert(signer_hex.clone(), make_owner(&signer_hex));
let commit = GroupStateCommit::sign(
"g1".into(),
2,
Some("wrong-prev".into()),
"r".into(),
"p".into(),
"m".into(),
None,
false,
0,
&kp,
)
.unwrap();
let ctx = ApplyContext {
current_state_hash: "current-real",
current_revision: 1,
current_withdrawn: false,
members_v2: &members,
group_id: "g1",
};
let err = validate_apply(&ctx, &commit, ActionKind::OwnerOnly).unwrap_err();
assert!(matches!(err, ApplyError::PrevHashMismatch { .. }));
}
#[test]
fn validate_apply_rejects_unauthorized_owner_action() {
let kp = AgentKeypair::generate().unwrap();
let signer_hex = hex::encode(kp.agent_id().as_bytes());
let owner_hex = "ff".repeat(32);
let mut members = BTreeMap::new();
members.insert(owner_hex.clone(), make_owner(&owner_hex));
members.insert(
signer_hex.clone(),
make_member(&signer_hex, GroupRole::Member),
);
let commit = GroupStateCommit::sign(
"g1".into(),
2,
Some("current".into()),
"r".into(),
"p".into(),
"m".into(),
None,
false,
0,
&kp,
)
.unwrap();
let ctx = ApplyContext {
current_state_hash: "current",
current_revision: 1,
current_withdrawn: false,
members_v2: &members,
group_id: "g1",
};
let err = validate_apply(&ctx, &commit, ActionKind::OwnerOnly).unwrap_err();
assert!(matches!(err, ApplyError::Unauthorized { .. }));
}
#[test]
fn validate_apply_allows_admin_action_from_admin() {
let kp = AgentKeypair::generate().unwrap();
let signer_hex = hex::encode(kp.agent_id().as_bytes());
let owner_hex = "ff".repeat(32);
let mut members = BTreeMap::new();
members.insert(owner_hex.clone(), make_owner(&owner_hex));
members.insert(
signer_hex.clone(),
make_member(&signer_hex, GroupRole::Admin),
);
let commit = GroupStateCommit::sign(
"g1".into(),
2,
Some("current".into()),
"r".into(),
"p".into(),
"m".into(),
None,
false,
0,
&kp,
)
.unwrap();
let ctx = ApplyContext {
current_state_hash: "current",
current_revision: 1,
current_withdrawn: false,
members_v2: &members,
group_id: "g1",
};
validate_apply(&ctx, &commit, ActionKind::AdminOrHigher).unwrap();
}
#[test]
fn validate_apply_rejects_post_withdrawal_non_withdrawal() {
let kp = AgentKeypair::generate().unwrap();
let signer_hex = hex::encode(kp.agent_id().as_bytes());
let mut members = BTreeMap::new();
members.insert(signer_hex.clone(), make_owner(&signer_hex));
let commit = GroupStateCommit::sign(
"g1".into(),
3,
Some("current".into()),
"r".into(),
"p".into(),
"m".into(),
None,
false, 0,
&kp,
)
.unwrap();
let ctx = ApplyContext {
current_state_hash: "current",
current_revision: 2,
current_withdrawn: true,
members_v2: &members,
group_id: "g1",
};
let err = validate_apply(&ctx, &commit, ActionKind::OwnerOnly).unwrap_err();
assert!(matches!(err, ApplyError::Withdrawn));
}
#[test]
fn validate_apply_rejects_wrong_group_id() {
let kp = AgentKeypair::generate().unwrap();
let signer_hex = hex::encode(kp.agent_id().as_bytes());
let mut members = BTreeMap::new();
members.insert(signer_hex.clone(), make_owner(&signer_hex));
let commit = GroupStateCommit::sign(
"g-wrong".into(),
2,
Some("current".into()),
"r".into(),
"p".into(),
"m".into(),
None,
false,
0,
&kp,
)
.unwrap();
let ctx = ApplyContext {
current_state_hash: "current",
current_revision: 1,
current_withdrawn: false,
members_v2: &members,
group_id: "g-right",
};
let err = validate_apply(&ctx, &commit, ActionKind::OwnerOnly).unwrap_err();
assert!(matches!(err, ApplyError::GroupIdMismatch { .. }));
}
}