use std::collections::BTreeMap;
use serde::Serialize;
use crate::crypto::{EventVerificationStatus, SignerId};
use crate::error::Result;
use crate::model::ActorId;
use crate::session::event::{
EventSignatureRecordedPayload, EventType, ShoreEvent, resolve_effective_signer,
};
use crate::session::{
ActorAttributesMap, CosignatureVerification, TrustSet, verify_cosignature,
verify_event_signature,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CosignatureSource {
Inline,
Detached { carrier_event_id: String },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CosignatureClassification {
Authoring { reason: Option<AuthoringReason> },
EndorsementTrusted { endorser: ActorId },
EndorsementUntrusted { reason: EndorsementUntrustedReason },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AuthoringReason {
AuthoringNotEndorsement,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EndorsementUntrustedReason {
UnknownEndorser,
AmbiguousEndorser,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CosignatureMember {
pub attesting_signer: SignerId,
pub status: EventVerificationStatus,
pub source: CosignatureSource,
pub classification: CosignatureClassification,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CosignatureSet {
pub target_event_id: String,
pub members: Vec<CosignatureMember>,
}
impl CosignatureSet {
pub(crate) fn inline_status(&self) -> Option<EventVerificationStatus> {
self.members
.iter()
.find(|member| member.source == CosignatureSource::Inline)
.map(|member| member.status)
}
pub(crate) fn has_valid_member(&self) -> bool {
self.members
.iter()
.any(|member| member.status == EventVerificationStatus::Valid)
}
pub(crate) fn has_trusted_endorsement(&self) -> bool {
self.members.iter().any(|member| {
matches!(
member.classification,
CosignatureClassification::EndorsementTrusted { .. }
)
})
}
pub(crate) fn has_untrusted_member(&self) -> bool {
self.members
.iter()
.any(|member| member.status == EventVerificationStatus::UntrustedKey)
}
}
pub(crate) struct CosignatureIndex<'a> {
carriers_by_target: BTreeMap<String, Vec<DetachedCarrier<'a>>>,
}
struct DetachedCarrier<'a> {
event: &'a ShoreEvent,
payload: EventSignatureRecordedPayload,
}
impl<'a> CosignatureIndex<'a> {
pub(crate) fn build(events: &'a [ShoreEvent]) -> Result<Self> {
let mut carriers_by_target: BTreeMap<String, Vec<DetachedCarrier<'a>>> = BTreeMap::new();
for event in events
.iter()
.filter(|event| event.event_type == EventType::EventSignatureRecorded)
{
let payload: EventSignatureRecordedPayload =
serde_json::from_value(event.payload.clone())?;
carriers_by_target
.entry(payload.target_event_id.as_str().to_owned())
.or_default()
.push(DetachedCarrier { event, payload });
}
Ok(Self { carriers_by_target })
}
pub(crate) fn cosignatures_for_target(
&self,
target: &ShoreEvent,
trust: &TrustSet,
) -> Result<CosignatureSet> {
let target_record_hash = target.event_record_hash()?;
let (inline_key, inline) = inline_member(target, &target_record_hash, trust)?;
let empty = Vec::new();
let carriers = self
.carriers_by_target
.get(target.event_id.as_str())
.unwrap_or(&empty);
let detached = detached_members(carriers, target, inline_key.as_deref(), trust)?;
let mut members = Vec::with_capacity(inline.is_some() as usize + detached.len());
if let Some(member) = inline {
members.push(member);
}
members.extend(detached.into_values());
Ok(CosignatureSet {
target_event_id: target.event_id.as_str().to_owned(),
members,
})
}
}
fn inline_member(
target: &ShoreEvent,
target_record_hash: &str,
trust: &TrustSet,
) -> Result<(Option<String>, Option<CosignatureMember>)> {
let Some(signature) = &target.signature else {
return Ok((None, None));
};
let status = verify_event_signature(target, trust)?;
let Some(attesting_signer) = resolve_effective_signer(target)
.ok()
.or_else(|| target.signer.clone())
else {
return Ok((None, None));
};
let key = EventSignatureRecordedPayload::idempotency_key(
target_record_hash,
&attesting_signer,
signature.sig.as_str(),
);
let classification = classify_cosignature_member(
&CosignatureSource::Inline,
status,
&attesting_signer,
&target.writer.actor_id,
trust,
);
Ok((
Some(key),
Some(CosignatureMember {
attesting_signer,
status,
source: CosignatureSource::Inline,
classification,
}),
))
}
fn detached_members(
carriers: &[DetachedCarrier<'_>],
target: &ShoreEvent,
inline_key: Option<&str>,
trust: &TrustSet,
) -> Result<BTreeMap<String, CosignatureMember>> {
let mut detached: BTreeMap<String, CosignatureMember> = BTreeMap::new();
for carrier in carriers {
let event = carrier.event;
let payload = &carrier.payload;
let status = match verify_cosignature(payload, target, trust)? {
CosignatureVerification::Attested(status @ EventVerificationStatus::Valid)
| CosignatureVerification::Attested(status @ EventVerificationStatus::UntrustedKey) => {
status
}
CosignatureVerification::Attested(_) | CosignatureVerification::BindingMismatch => {
continue;
}
};
if inline_key == Some(event.idempotency_key.as_str()) {
continue;
}
let source = CosignatureSource::Detached {
carrier_event_id: event.event_id.as_str().to_owned(),
};
let classification = classify_cosignature_member(
&source,
status,
&payload.attesting_signer,
&target.writer.actor_id,
trust,
);
detached
.entry(event.idempotency_key.clone())
.or_insert_with(|| CosignatureMember {
attesting_signer: payload.attesting_signer.clone(),
status,
source,
classification,
});
}
Ok(detached)
}
pub(crate) fn cosignatures_for_event(
events: &[ShoreEvent],
target_event_id: &str,
trust: &TrustSet,
) -> Result<CosignatureSet> {
let Some(target) = events
.iter()
.find(|event| event.event_id.as_str() == target_event_id)
else {
return Ok(CosignatureSet {
target_event_id: target_event_id.to_owned(),
members: Vec::new(),
});
};
CosignatureIndex::build(events)?.cosignatures_for_target(target, trust)
}
pub(crate) fn classify_cosignature_member(
source: &CosignatureSource,
status: EventVerificationStatus,
attesting_signer: &SignerId,
target_actor: &ActorId,
trust: &TrustSet,
) -> CosignatureClassification {
if matches!(source, CosignatureSource::Inline) {
return CosignatureClassification::Authoring { reason: None };
}
match status {
EventVerificationStatus::Valid => {
let launders = trust
.reverse_resolve(attesting_signer)
.into_iter()
.any(|actor| actor != *target_actor);
CosignatureClassification::Authoring {
reason: launders.then_some(AuthoringReason::AuthoringNotEndorsement),
}
}
EventVerificationStatus::UntrustedKey => {
let actors = trust.reverse_resolve(attesting_signer);
match actors.len() {
1 => CosignatureClassification::EndorsementTrusted {
endorser: actors.into_iter().next().expect("len checked"),
},
0 => CosignatureClassification::EndorsementUntrusted {
reason: EndorsementUntrustedReason::UnknownEndorser,
},
_ => CosignatureClassification::EndorsementUntrusted {
reason: EndorsementUntrustedReason::AmbiguousEndorser,
},
}
}
EventVerificationStatus::Invalid | EventVerificationStatus::Unsigned => {
CosignatureClassification::Authoring { reason: None }
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EndorsementClassification {
#[serde(rename = "endorsement-trusted")]
EndorsementTrusted,
UnknownEndorser,
AmbiguousEndorser,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EndorsementReadback {
pub classification: EndorsementClassification,
#[serde(skip_serializing_if = "Option::is_none")]
pub endorser: Option<ActorId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endorser_attributes: Option<EndorserAttributesView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EndorserAttributesView {
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
}
pub(crate) fn endorsement_readbacks(set: &CosignatureSet) -> Vec<EndorsementReadback> {
set.members
.iter()
.filter_map(|member| match &member.classification {
CosignatureClassification::EndorsementTrusted { endorser } => {
Some(EndorsementReadback {
classification: EndorsementClassification::EndorsementTrusted,
endorser: Some(endorser.clone()),
reason: None,
endorser_attributes: None,
})
}
CosignatureClassification::EndorsementUntrusted { reason } => {
Some(EndorsementReadback {
classification: match reason {
EndorsementUntrustedReason::UnknownEndorser => {
EndorsementClassification::UnknownEndorser
}
EndorsementUntrustedReason::AmbiguousEndorser => {
EndorsementClassification::AmbiguousEndorser
}
},
endorser: None,
reason: None,
endorser_attributes: None,
})
}
CosignatureClassification::Authoring { .. } => None,
})
.collect()
}
pub(crate) fn enrich_endorser_attributes(
readbacks: &mut [EndorsementReadback],
attributes: Option<&ActorAttributesMap>,
) {
let Some(attributes) = attributes else {
return;
};
for readback in readbacks.iter_mut() {
let Some(endorser) = readback.endorser.as_ref() else {
continue;
};
let resolved = attributes.resolve(endorser);
if resolved.kind().is_none() && resolved.roles().is_empty() {
continue;
}
readback.endorser_attributes = Some(EndorserAttributesView {
kind: resolved.kind().map(str::to_owned),
roles: resolved.roles().iter().cloned().collect(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{EventSignatureBytes, EventSigner};
use crate::session::event::{
EventSignature, EventToBeSigned, event_signature_pre_authentication_encoding,
};
use crate::session::projection::freshness::event_set_hash_for_events;
use crate::session::signing::test_support::{DeterministicSigner, trust_for_actor};
const SIGNER_A_SEED: [u8; 32] = [61u8; 32];
const SIGNER_B_SEED: [u8; 32] = [62u8; 32];
fn fixture_target() -> ShoreEvent {
serde_json::from_str(include_str!(
"../../../tests/fixtures/event_signatures/friendly-valid-event.json"
))
.expect("fixture event decodes")
}
fn inline_signed(signer: &DeterministicSigner) -> ShoreEvent {
let mut event = fixture_target();
event.signer = None;
event.signature = None;
let tbs = EventToBeSigned::from_event(&event, signer.signer_id()).unwrap();
let pae = event_signature_pre_authentication_encoding(&tbs).unwrap();
let sig = signer.sign_event_message(&pae).unwrap();
event.signer = Some(signer.signer_id().clone());
event.signature = Some(EventSignature::ed25519_v1(sig));
event
}
fn detached_carrier(target: &ShoreEvent, signer: &DeterministicSigner) -> ShoreEvent {
let attesting_signer = signer.signer_id().clone();
let tbs = EventToBeSigned::from_event(target, &attesting_signer).unwrap();
let pae = event_signature_pre_authentication_encoding(&tbs).unwrap();
let sig = signer.sign_event_message(&pae).unwrap();
let payload = EventSignatureRecordedPayload {
target_event_id: target.event_id.clone(),
target_event_record_hash: target.event_record_hash().unwrap(),
attesting_signer,
attestation: EventSignature::ed25519_v1(sig),
inclusion_proof: None,
};
let key = EventSignatureRecordedPayload::idempotency_key(
&target.event_record_hash().unwrap(),
signer.signer_id(),
payload.attestation.sig.as_str(),
);
crate::session::event::ShoreEvent::new(
EventType::EventSignatureRecorded,
key,
crate::session::event::EventTarget::for_journal(target.target.journal_id.clone()),
crate::session::event::Writer::shore_local("test"),
payload,
"2026-06-04T00:00:00Z",
)
.unwrap()
}
fn two_signer_trust(
actor: &crate::model::ActorId,
a: &DeterministicSigner,
b: &DeterministicSigner,
) -> TrustSet {
crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": {
actor.as_str(): [a.signer_id().as_str(), b.signer_id().as_str()],
}
}))
.unwrap()
}
#[test]
fn index_matches_single_shot_for_every_target() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let endorser = crate::model::ActorId::new("actor:git-email:kevin@swiber.dev");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": {
target.writer.actor_id.as_str(): [signer_a.signer_id().as_str()],
endorser.as_str(): [signer_b.signer_id().as_str()]
}
}))
.unwrap();
let events = vec![target, carrier];
let index = CosignatureIndex::build(&events).unwrap();
for target in &events {
let via_index = index.cosignatures_for_target(target, &trust).unwrap();
let via_single =
cosignatures_for_event(&events, target.event_id.as_str(), &trust).unwrap();
assert_eq!(
via_index,
via_single,
"index path must equal single-shot for {}",
target.event_id.as_str()
);
}
}
#[test]
fn endorsement_readbacks_lowers_trusted_and_untrusted_members() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let signer_c = DeterministicSigner::from_seed([63u8; 32]);
let target = inline_signed(&signer_a);
let carrier_trusted = detached_carrier(&target, &signer_b);
let carrier_unknown = detached_carrier(&target, &signer_c);
let endorser = crate::model::ActorId::new("actor:git-email:a@example.com");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": {
target.writer.actor_id.as_str(): [signer_a.signer_id().as_str()],
endorser.as_str(): [signer_b.signer_id().as_str()]
}
}))
.unwrap();
let events = vec![target.clone(), carrier_trusted, carrier_unknown];
let set = cosignatures_for_event(&events, target.event_id.as_str(), &trust).unwrap();
let readbacks = endorsement_readbacks(&set);
assert_eq!(readbacks.len(), 2);
let trusted = readbacks
.iter()
.find(|r| r.classification == EndorsementClassification::EndorsementTrusted)
.expect("a trusted endorsement");
assert_eq!(
trusted.endorser.as_ref().map(|a| a.as_str()),
Some("actor:git-email:a@example.com")
);
let unknown = readbacks
.iter()
.find(|r| r.classification == EndorsementClassification::UnknownEndorser)
.expect("an unknown endorsement");
assert!(unknown.endorser.is_none());
assert!(readbacks.iter().all(|r| r.endorser_attributes.is_none()));
}
#[test]
fn endorsement_classification_serializes_kebab_and_snake() {
assert_eq!(
serde_json::to_value(EndorsementClassification::EndorsementTrusted).unwrap(),
serde_json::json!("endorsement-trusted")
);
assert_eq!(
serde_json::to_value(EndorsementClassification::UnknownEndorser).unwrap(),
serde_json::json!("unknown_endorser")
);
assert_eq!(
serde_json::to_value(EndorsementClassification::AmbiguousEndorser).unwrap(),
serde_json::json!("ambiguous_endorser")
);
}
#[test]
fn enrich_sets_kind_and_roles_only_for_resolved_endorsers() {
let mut readbacks = vec![
EndorsementReadback {
classification: EndorsementClassification::EndorsementTrusted,
endorser: Some(ActorId::new("actor:git-email:a@example.com")),
reason: None,
endorser_attributes: None,
},
EndorsementReadback {
classification: EndorsementClassification::UnknownEndorser,
endorser: None,
reason: None,
endorser_attributes: None,
},
];
let attrs = crate::session::actor_attributes_from_value(serde_json::json!({
"actors": {
"actor:git-email:a@example.com": { "kind": "human", "roles": ["reviewer"] }
}
}))
.unwrap();
enrich_endorser_attributes(&mut readbacks, Some(&attrs));
let enriched = readbacks[0].endorser_attributes.as_ref().unwrap();
assert_eq!(enriched.kind.as_deref(), Some("human"));
assert!(enriched.roles.contains(&"reviewer".to_string()));
assert!(
readbacks[1].endorser_attributes.is_none(),
"no endorser → no attributes"
);
}
#[test]
fn enrich_is_a_noop_without_a_map() {
let mut readbacks = vec![EndorsementReadback {
classification: EndorsementClassification::EndorsementTrusted,
endorser: Some(ActorId::new("actor:git-email:a@example.com")),
reason: None,
endorser_attributes: None,
}];
enrich_endorser_attributes(&mut readbacks, None);
assert!(readbacks[0].endorser_attributes.is_none());
}
#[test]
fn two_signer_fact_projects_a_two_member_set() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let trust = two_signer_trust(&target.writer.actor_id.clone(), &signer_a, &signer_b);
let set =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
assert_eq!(set.members.len(), 2);
let inline = &set.members[0];
assert_eq!(inline.source, CosignatureSource::Inline);
assert_eq!(inline.attesting_signer, *signer_a.signer_id());
assert_eq!(inline.status, EventVerificationStatus::Valid);
let detached = &set.members[1];
assert!(matches!(
detached.source,
CosignatureSource::Detached { .. }
));
assert_eq!(detached.attesting_signer, *signer_b.signer_id());
assert_eq!(detached.status, EventVerificationStatus::Valid);
}
#[test]
fn identical_resubmitted_attestation_does_not_double_count() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let trust = two_signer_trust(&target.writer.actor_id.clone(), &signer_a, &signer_b);
let set = cosignatures_for_event(
&[target.clone(), carrier.clone(), carrier],
target.event_id.as_str(),
&trust,
)
.unwrap();
assert_eq!(
set.members.len(),
2,
"the duplicate carrier collapses to one member"
);
}
#[test]
fn inline_and_detached_of_the_same_attestation_dedup_to_one_member() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_a);
let trust = trust_for_actor(&target.writer.actor_id.clone(), &signer_a);
let set =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
assert_eq!(set.members.len(), 1);
assert_eq!(set.members[0].source, CosignatureSource::Inline);
}
#[test]
fn projection_is_order_independent() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let trust = two_signer_trust(&target.writer.actor_id.clone(), &signer_a, &signer_b);
let forward = cosignatures_for_event(
&[target.clone(), carrier.clone()],
target.event_id.as_str(),
&trust,
)
.unwrap();
let reversed =
cosignatures_for_event(&[carrier, target.clone()], target.event_id.as_str(), &trust)
.unwrap();
assert_eq!(forward, reversed);
}
#[test]
fn unsigned_target_has_empty_inline_slot() {
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let mut target = fixture_target();
target.signer = None;
target.signature = None;
let trust = trust_for_actor(&target.writer.actor_id.clone(), &signer_b);
let empty = cosignatures_for_event(
std::slice::from_ref(&target),
target.event_id.as_str(),
&trust,
)
.unwrap();
assert!(empty.members.is_empty());
let carrier = detached_carrier(&target, &signer_b);
let with_detached =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
assert_eq!(with_detached.members.len(), 1);
assert!(matches!(
with_detached.members[0].source,
CosignatureSource::Detached { .. }
));
}
#[test]
fn inline_member_may_be_invalid_detached_members_are_not() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let mut target = inline_signed(&signer_a);
target.signature = Some(EventSignature::ed25519_v1(EventSignatureBytes::from_bytes(
&[0u8; 64],
)));
let carrier = detached_carrier(&target, &signer_b);
let trust = two_signer_trust(&target.writer.actor_id.clone(), &signer_a, &signer_b);
let set =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
let inline = set
.members
.iter()
.find(|member| member.source == CosignatureSource::Inline)
.unwrap();
assert_eq!(inline.status, EventVerificationStatus::Invalid);
let detached = set
.members
.iter()
.find(|member| matches!(member.source, CosignatureSource::Detached { .. }))
.unwrap();
assert_eq!(detached.status, EventVerificationStatus::Valid);
}
#[test]
fn untrusted_detached_member_is_kept() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let trust = trust_for_actor(&target.writer.actor_id.clone(), &signer_a);
let set =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
let detached = set
.members
.iter()
.find(|member| matches!(member.source, CosignatureSource::Detached { .. }))
.unwrap();
assert_eq!(detached.status, EventVerificationStatus::UntrustedKey);
}
fn signer(seed: u8) -> SignerId {
crate::crypto::SignerId::from_ed25519_public_key([seed; 32])
}
#[test]
fn projected_inline_member_is_authoring_classification() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let target = inline_signed(&signer_a);
let trust = trust_for_actor(&target.writer.actor_id.clone(), &signer_a);
let set = cosignatures_for_event(
std::slice::from_ref(&target),
target.event_id.as_str(),
&trust,
)
.unwrap();
let inline = set
.members
.iter()
.find(|m| m.source == CosignatureSource::Inline)
.unwrap();
assert!(matches!(
inline.classification,
CosignatureClassification::Authoring { reason: None }
));
}
#[test]
fn projected_endorsement_member_is_endorsement_trusted() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let endorser = crate::model::ActorId::new("actor:git-email:kevin@swiber.dev");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": {
target.writer.actor_id.as_str(): [signer_a.signer_id().as_str()],
endorser.as_str(): [signer_b.signer_id().as_str()]
}
}))
.unwrap();
let set =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
let detached = set
.members
.iter()
.find(|m| matches!(m.source, CosignatureSource::Detached { .. }))
.unwrap();
assert_eq!(detached.status, EventVerificationStatus::UntrustedKey);
assert!(matches!(
&detached.classification,
CosignatureClassification::EndorsementTrusted { endorser: e } if *e == endorser
));
}
#[test]
fn inline_member_is_always_authoring() {
let trust = TrustSet::default();
let target = ActorId::new("actor:agent:claude-code");
let c = classify_cosignature_member(
&CosignatureSource::Inline,
EventVerificationStatus::UntrustedKey, &signer(1),
&target,
&trust,
);
assert!(matches!(
c,
CosignatureClassification::Authoring { reason: None }
));
}
#[test]
fn detached_valid_is_authoring() {
let target = ActorId::new("actor:agent:claude-code");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": { target.as_str(): [signer(2).as_str()] }
}))
.unwrap();
let c = classify_cosignature_member(
&CosignatureSource::Detached {
carrier_event_id: "evt:sha256:x".into(),
},
EventVerificationStatus::Valid,
&signer(2),
&target,
&trust,
);
assert!(matches!(
c,
CosignatureClassification::Authoring { reason: None }
));
}
#[test]
fn detached_valid_signer_mapping_to_a_distinct_actor_is_authoring_not_endorsement() {
let target = ActorId::new("actor:agent:claude-code");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": {
target.as_str(): [signer(3).as_str()],
"actor:git-email:kevin@swiber.dev": [signer(3).as_str()]
}
}))
.unwrap();
let c = classify_cosignature_member(
&CosignatureSource::Detached {
carrier_event_id: "evt:sha256:x".into(),
},
EventVerificationStatus::Valid,
&signer(3),
&target,
&trust,
);
assert!(matches!(
c,
CosignatureClassification::Authoring {
reason: Some(AuthoringReason::AuthoringNotEndorsement)
}
));
}
#[test]
fn detached_untrusted_resolving_to_one_distinct_actor_is_endorsement_trusted() {
let target = ActorId::new("actor:agent:claude-code");
let endorser = ActorId::new("actor:git-email:kevin@swiber.dev");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": { endorser.as_str(): [signer(4).as_str()] }
}))
.unwrap();
let c = classify_cosignature_member(
&CosignatureSource::Detached {
carrier_event_id: "evt:sha256:x".into(),
},
EventVerificationStatus::UntrustedKey,
&signer(4),
&target,
&trust,
);
assert!(matches!(
c,
CosignatureClassification::EndorsementTrusted { endorser: e } if e == endorser
));
}
#[test]
fn detached_untrusted_unenrolled_signer_is_unknown_endorser() {
let target = ActorId::new("actor:agent:claude-code");
let trust = TrustSet::default(); let c = classify_cosignature_member(
&CosignatureSource::Detached {
carrier_event_id: "evt:sha256:x".into(),
},
EventVerificationStatus::UntrustedKey,
&signer(5),
&target,
&trust,
);
assert!(matches!(
c,
CosignatureClassification::EndorsementUntrusted {
reason: EndorsementUntrustedReason::UnknownEndorser
}
));
}
#[test]
fn detached_untrusted_resolving_to_many_actors_is_ambiguous_endorser() {
let target = ActorId::new("actor:agent:claude-code");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": {
"actor:git-email:kevin@swiber.dev": [signer(6).as_str()],
"actor:git-email:alice@example.com": [signer(6).as_str()]
}
}))
.unwrap();
let c = classify_cosignature_member(
&CosignatureSource::Detached {
carrier_event_id: "evt:sha256:x".into(),
},
EventVerificationStatus::UntrustedKey,
&signer(6),
&target,
&trust,
);
assert!(matches!(
c,
CosignatureClassification::EndorsementUntrusted {
reason: EndorsementUntrustedReason::AmbiguousEndorser
}
));
}
#[test]
fn has_trusted_endorsement_true_only_with_an_endorsement_trusted_member() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let endorser = crate::model::ActorId::new("actor:git-email:kevin@swiber.dev");
let trust = crate::session::event_signature_trust_set(serde_json::json!({
"allowedSigners": {
target.writer.actor_id.as_str(): [signer_a.signer_id().as_str()],
endorser.as_str(): [signer_b.signer_id().as_str()]
}
}))
.unwrap();
let set =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
assert!(
set.has_trusted_endorsement(),
"an own-identity endorser is a trusted endorsement"
);
assert!(
!set.has_valid_member() || set.inline_status() == Some(EventVerificationStatus::Valid)
);
}
#[test]
fn has_trusted_endorsement_false_for_authoring_only_set() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let target = inline_signed(&signer_a);
let trust = trust_for_actor(&target.writer.actor_id.clone(), &signer_a);
let set = cosignatures_for_event(
std::slice::from_ref(&target),
target.event_id.as_str(),
&trust,
)
.unwrap();
assert!(!set.has_trusted_endorsement());
assert!(
set.has_valid_member(),
"the inline author attestation still binds (unchanged)"
);
}
#[test]
fn has_trusted_endorsement_false_for_unknown_endorser() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let trust = trust_for_actor(&target.writer.actor_id.clone(), &signer_a);
let set =
cosignatures_for_event(&[target.clone(), carrier], target.event_id.as_str(), &trust)
.unwrap();
assert!(!set.has_trusted_endorsement());
}
#[test]
fn cosignature_events_are_in_event_set_hash() {
let signer_a = DeterministicSigner::from_seed(SIGNER_A_SEED);
let signer_b = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&signer_a);
let carrier = detached_carrier(&target, &signer_b);
let target_only = event_set_hash_for_events([&target]).unwrap();
let with_carrier = event_set_hash_for_events([&target, &carrier]).unwrap();
let reversed = event_set_hash_for_events([&carrier, &target]).unwrap();
assert_ne!(
target_only, with_carrier,
"the carrier rides the shipped set hash"
);
assert_eq!(with_carrier, reversed, "the set hash is order-independent");
}
#[test]
fn endorsement_carriers_for_one_triple_converge_byte_identical_across_writers() {
use crate::session::event::{EventTarget, Writer, WriterProducer};
let signer = DeterministicSigner::from_seed(SIGNER_B_SEED);
let target = inline_signed(&DeterministicSigner::from_seed(SIGNER_A_SEED));
let attesting_signer = signer.signer_id().clone();
let record_hash = target.event_record_hash().unwrap();
let tbs = EventToBeSigned::from_event(&target, &attesting_signer).unwrap();
let pae = event_signature_pre_authentication_encoding(&tbs).unwrap();
let sig = signer.sign_event_message(&pae).unwrap();
let payload = EventSignatureRecordedPayload {
target_event_id: target.event_id.clone(),
target_event_record_hash: record_hash.clone(),
attesting_signer: attesting_signer.clone(),
attestation: EventSignature::ed25519_v1(sig),
inclusion_proof: None,
};
let key = EventSignatureRecordedPayload::idempotency_key(
&record_hash,
&attesting_signer,
payload.attestation.sig.as_str(),
);
let payload_json = serde_json::to_value(&payload).unwrap();
let keys: std::collections::BTreeSet<&str> = payload_json
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
[
"attestation",
"attestingSigner",
"targetEventId",
"targetEventRecordHash"
]
.into_iter()
.collect::<std::collections::BTreeSet<_>>(),
"endorsement carrier payload must carry no meaning/relation field"
);
for forbidden in ["relation", "endorser", "classification"] {
assert!(
!payload_json.as_object().unwrap().contains_key(forbidden),
"carrier payload must not carry `{forbidden}` (INV-2: derived or identity-bearing, never an excluded payload field)"
);
}
let carrier = |actor: &str| {
ShoreEvent::new(
EventType::EventSignatureRecorded,
key.clone(),
EventTarget::for_journal(target.target.journal_id.clone()),
Writer {
actor_id: crate::model::ActorId::new(actor),
producer: WriterProducer {
name: "shore".into(),
version: "test".into(),
},
},
payload.clone(),
"2026-06-04T00:00:00Z",
)
.unwrap()
};
let mirror_a = carrier("actor:git-email:alice@example.com");
let mirror_b = carrier("actor:agent:bob");
assert_ne!(mirror_a.writer.actor_id, mirror_b.writer.actor_id);
assert_eq!(mirror_a.idempotency_key, mirror_b.idempotency_key);
assert_eq!(mirror_a.event_id, mirror_b.event_id);
assert_eq!(mirror_a.payload_hash, mirror_b.payload_hash);
assert_eq!(
event_set_hash_for_events([&mirror_a]).unwrap(),
event_set_hash_for_events([&mirror_b]).unwrap(),
"envelope-only writer differences must not affect eventSetHash"
);
}
}