use serde::{Deserialize, Serialize};
use super::kind::EventType;
use super::payload::EventPayload;
use super::type_code::type_code;
use crate::canonical_hash::sha256_json_prefixed;
use crate::error::Result;
use crate::model::{
CommitAssociationId, CommitWithdrawalId, RefAssociationId, RefWithdrawalId, ReviewEndpoint,
ReviewTargetRef, RevisionId, id_prefix,
};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevisionCommitAssociatedPayload {
pub commit_association_id: CommitAssociationId,
pub target: ReviewTargetRef,
pub commit: ReviewEndpoint,
}
impl RevisionCommitAssociatedPayload {
pub fn idempotency_key(revision_id: &RevisionId, commit_oid: &str) -> String {
format!(
"{}:{}:{}",
type_code(EventType::RevisionCommitAssociated),
revision_id.as_str(),
commit_oid
)
}
}
impl EventPayload for RevisionCommitAssociatedPayload {
fn event_type(&self) -> EventType {
EventType::RevisionCommitAssociated
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevisionRefAssociatedPayload {
pub ref_association_id: RefAssociationId,
pub target: ReviewTargetRef,
pub ref_name: String,
pub head_oid: String,
}
impl RevisionRefAssociatedPayload {
pub fn idempotency_key(revision_id: &RevisionId, ref_name: &str, head_oid: &str) -> String {
format!(
"{}:{}:{}",
type_code(EventType::RevisionRefAssociated),
revision_id.as_str(),
ref_distinguisher(ref_name, head_oid)
)
}
}
impl EventPayload for RevisionRefAssociatedPayload {
fn event_type(&self) -> EventType {
EventType::RevisionRefAssociated
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevisionCommitWithdrawnPayload {
pub commit_withdrawal_id: CommitWithdrawalId,
pub target: ReviewTargetRef,
pub commit_association_id: CommitAssociationId,
}
impl RevisionCommitWithdrawnPayload {
pub fn idempotency_key(commit_association_id: &CommitAssociationId) -> String {
format!(
"{}:{}",
type_code(EventType::RevisionCommitWithdrawn),
commit_association_id.as_str()
)
}
}
impl EventPayload for RevisionCommitWithdrawnPayload {
fn event_type(&self) -> EventType {
EventType::RevisionCommitWithdrawn
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevisionRefWithdrawnPayload {
pub ref_withdrawal_id: RefWithdrawalId,
pub target: ReviewTargetRef,
pub ref_association_id: RefAssociationId,
}
impl RevisionRefWithdrawnPayload {
pub fn idempotency_key(ref_association_id: &RefAssociationId) -> String {
format!(
"{}:{}",
type_code(EventType::RevisionRefWithdrawn),
ref_association_id.as_str()
)
}
}
impl EventPayload for RevisionRefWithdrawnPayload {
fn event_type(&self) -> EventType {
EventType::RevisionRefWithdrawn
}
}
fn ref_distinguisher(ref_name: &str, head_oid: &str) -> String {
format!("{ref_name}@{head_oid}")
}
pub(crate) fn build_commit_association_id(
revision_id: &RevisionId,
commit_oid: &str,
) -> Result<CommitAssociationId> {
let digest = sha256_json_prefixed(&serde_json::json!({
"revisionId": revision_id.as_str(),
"commitOid": commit_oid,
}))?;
Ok(CommitAssociationId::new(format!(
"{}:{digest}",
id_prefix::COMMIT_ASSOCIATION
)))
}
pub(crate) fn build_ref_association_id(
revision_id: &RevisionId,
ref_name: &str,
head_oid: &str,
) -> Result<RefAssociationId> {
let digest = sha256_json_prefixed(&serde_json::json!({
"revisionId": revision_id.as_str(),
"refName": ref_name,
"headOid": head_oid,
}))?;
Ok(RefAssociationId::new(format!(
"{}:{digest}",
id_prefix::REF_ASSOCIATION
)))
}
pub(crate) fn build_commit_withdrawal_id(
revision_id: &RevisionId,
commit_association_id: &CommitAssociationId,
) -> Result<CommitWithdrawalId> {
let digest = sha256_json_prefixed(&serde_json::json!({
"revisionId": revision_id.as_str(),
"commitAssociationId": commit_association_id.as_str(),
}))?;
Ok(CommitWithdrawalId::new(format!(
"{}:{digest}",
id_prefix::COMMIT_WITHDRAWAL
)))
}
pub(crate) fn build_ref_withdrawal_id(
revision_id: &RevisionId,
ref_association_id: &RefAssociationId,
) -> Result<RefWithdrawalId> {
let digest = sha256_json_prefixed(&serde_json::json!({
"revisionId": revision_id.as_str(),
"refAssociationId": ref_association_id.as_str(),
}))?;
Ok(RefWithdrawalId::new(format!(
"{}:{digest}",
id_prefix::REF_WITHDRAWAL
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commit_associated_idempotency_key_is_track_free() {
let ru = RevisionId::new("ru:sha256:abc");
assert_eq!(
RevisionCommitAssociatedPayload::idempotency_key(&ru, "oid123"),
format!(
"{}:ru:sha256:abc:oid123",
type_code(EventType::RevisionCommitAssociated)
)
);
}
#[test]
fn ref_associated_idempotency_key_joins_name_and_head() {
let ru = RevisionId::new("ru:sha256:abc");
assert_eq!(
RevisionRefAssociatedPayload::idempotency_key(&ru, "refs/heads/feat/x", "oidH"),
format!(
"{}:ru:sha256:abc:refs/heads/feat/x@oidH",
type_code(EventType::RevisionRefAssociated)
)
);
}
#[test]
fn withdrawal_keys_use_the_association_id() {
let cid = CommitAssociationId::new("assoc-commit:sha256:zzz");
assert_eq!(
RevisionCommitWithdrawnPayload::idempotency_key(&cid),
format!(
"{}:assoc-commit:sha256:zzz",
type_code(EventType::RevisionCommitWithdrawn)
)
);
let rid = RefAssociationId::new("assoc-ref:sha256:yyy");
assert_eq!(
RevisionRefWithdrawnPayload::idempotency_key(&rid),
format!(
"{}:assoc-ref:sha256:yyy",
type_code(EventType::RevisionRefWithdrawn)
)
);
}
#[test]
fn payloads_round_trip_camel_case_and_report_event_type() {
let p = RevisionCommitAssociatedPayload {
commit_association_id: CommitAssociationId::new("assoc-commit:sha256:zzz"),
target: ReviewTargetRef::Revision {
revision_id: RevisionId::new("ru:sha256:abc"),
},
commit: ReviewEndpoint::GitCommit {
commit_oid: "oid123".into(),
tree_oid: "tree9".into(),
},
};
assert_eq!(p.event_type(), EventType::RevisionCommitAssociated);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["commitAssociationId"], "assoc-commit:sha256:zzz");
assert_eq!(v["commit"]["kind"], "git_commit");
let back: RevisionCommitAssociatedPayload = serde_json::from_value(v).unwrap();
assert_eq!(back, p);
}
#[test]
fn ref_associated_payload_round_trips_camel_case_and_reports_event_type() {
let p = RevisionRefAssociatedPayload {
ref_association_id: RefAssociationId::new("assoc-ref:sha256:yyy"),
target: ReviewTargetRef::Revision {
revision_id: RevisionId::new("ru:sha256:abc"),
},
ref_name: "refs/heads/feat/x".into(),
head_oid: "oidH".into(),
};
assert_eq!(p.event_type(), EventType::RevisionRefAssociated);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["refAssociationId"], "assoc-ref:sha256:yyy");
assert_eq!(v["refName"], "refs/heads/feat/x");
assert_eq!(v["headOid"], "oidH");
let back: RevisionRefAssociatedPayload = serde_json::from_value(v).unwrap();
assert_eq!(back, p);
}
#[test]
fn withdrawal_payloads_are_ids_only_and_round_trip() {
let commit = RevisionCommitWithdrawnPayload {
commit_withdrawal_id: CommitWithdrawalId::new("withdraw-commit:sha256:w1"),
target: ReviewTargetRef::Revision {
revision_id: RevisionId::new("ru:sha256:abc"),
},
commit_association_id: CommitAssociationId::new("assoc-commit:sha256:zzz"),
};
assert_eq!(commit.event_type(), EventType::RevisionCommitWithdrawn);
let v = serde_json::to_value(&commit).unwrap();
assert_eq!(v["commitWithdrawalId"], "withdraw-commit:sha256:w1");
assert_eq!(v["commitAssociationId"], "assoc-commit:sha256:zzz");
assert!(
v.get("reason").is_none(),
"withdrawal payload has no reason"
);
let back: RevisionCommitWithdrawnPayload = serde_json::from_value(v).unwrap();
assert_eq!(back, commit);
let r = RevisionRefWithdrawnPayload {
ref_withdrawal_id: RefWithdrawalId::new("withdraw-ref:sha256:w2"),
target: ReviewTargetRef::Revision {
revision_id: RevisionId::new("ru:sha256:abc"),
},
ref_association_id: RefAssociationId::new("assoc-ref:sha256:yyy"),
};
assert_eq!(r.event_type(), EventType::RevisionRefWithdrawn);
let rv = serde_json::to_value(&r).unwrap();
assert_eq!(rv["refWithdrawalId"], "withdraw-ref:sha256:w2");
assert!(rv.get("reason").is_none());
let rback: RevisionRefWithdrawnPayload = serde_json::from_value(rv).unwrap();
assert_eq!(rback, r);
}
#[test]
fn commit_association_id_is_deterministic_and_distinguisher_scoped() {
let ru = RevisionId::new("ru:sha256:abc");
let a = build_commit_association_id(&ru, "oid123").unwrap();
let b = build_commit_association_id(&ru, "oid123").unwrap();
assert_eq!(a, b);
assert!(a.as_str().starts_with("assoc-commit:"));
let other = build_commit_association_id(&ru, "oid999").unwrap();
assert_ne!(a, other);
}
#[test]
fn ref_association_id_folds_name_and_head() {
let ru = RevisionId::new("ru:sha256:abc");
let a = build_ref_association_id(&ru, "refs/heads/feat/x", "oidH").unwrap();
let b = build_ref_association_id(&ru, "refs/heads/feat/x", "oidH2").unwrap();
assert_ne!(a, b);
assert!(a.as_str().starts_with("assoc-ref:"));
let other_ref = build_ref_association_id(&ru, "refs/heads/main", "oidH").unwrap();
assert_ne!(a, other_ref);
}
#[test]
fn withdrawal_id_folds_the_association_id() {
let ru = RevisionId::new("ru:sha256:abc");
let cid = build_commit_association_id(&ru, "oid123").unwrap();
let w = build_commit_withdrawal_id(&ru, &cid).unwrap();
assert!(w.as_str().starts_with("withdraw-commit:"));
assert_eq!(w, build_commit_withdrawal_id(&ru, &cid).unwrap());
let rid = build_ref_association_id(&ru, "refs/heads/feat/x", "oidH").unwrap();
let rw = build_ref_withdrawal_id(&ru, &rid).unwrap();
assert!(rw.as_str().starts_with("withdraw-ref:"));
assert_eq!(rw, build_ref_withdrawal_id(&ru, &rid).unwrap());
}
#[test]
fn commit_association_id_digest_is_pinned() {
let ru = RevisionId::new("ru:sha256:abc");
let id = build_commit_association_id(&ru, "oid123").unwrap();
assert_eq!(
id.as_str(),
"assoc-commit:sha256:b5b51f0ba5f3fb4372a4ba488a3fa66c255ceb551500789a8de24739738e1db7",
"pinned digest mismatch — id material shape changed"
);
}
#[test]
fn association_ids_fold_the_revision_id_digest_key() {
let revision_id = RevisionId::new("rev:sha256:abc");
let commit_material = sha256_json_prefixed(&serde_json::json!({
"revisionId": revision_id.as_str(),
"commitOid": "oid123",
}))
.unwrap();
assert_eq!(
build_commit_association_id(&revision_id, "oid123")
.unwrap()
.as_str(),
format!("assoc-commit:{commit_material}")
);
let ref_material = sha256_json_prefixed(&serde_json::json!({
"revisionId": revision_id.as_str(),
"refName": "refs/heads/feat/x",
"headOid": "oidH",
}))
.unwrap();
assert_eq!(
build_ref_association_id(&revision_id, "refs/heads/feat/x", "oidH")
.unwrap()
.as_str(),
format!("assoc-ref:{ref_material}")
);
}
}
#[cfg(test)]
mod convergence_tests {
use super::*;
use crate::model::{ActorId, JournalId, RevisionId, TrackId};
use crate::session::event::{EventTarget, ShoreEvent, Writer, WriterProducer};
use crate::session::projection::freshness::event_set_hash_for_events;
use crate::session::{EventStore, EventWriteOutcome};
fn revision() -> RevisionId {
RevisionId::new("ru:sha256:abc")
}
fn target_for(revision_id: &RevisionId, track: &str) -> EventTarget {
EventTarget::for_revision(
JournalId::new("journal:default"),
revision_id.clone(),
Some(TrackId::new(track)),
)
.unwrap()
}
fn writer_for(writer: &str) -> Writer {
Writer {
actor_id: ActorId::new(format!("actor:{writer}")),
producer: WriterProducer {
name: "shore".to_owned(),
version: "test".to_owned(),
},
}
}
fn commit_assoc_event(commit_oid: &str, writer: &str, track: &str) -> ShoreEvent {
let ru = revision();
let cid = build_commit_association_id(&ru, commit_oid).unwrap();
let payload = RevisionCommitAssociatedPayload {
commit_association_id: cid,
target: ReviewTargetRef::Revision {
revision_id: ru.clone(),
},
commit: ReviewEndpoint::GitCommit {
commit_oid: commit_oid.to_owned(),
tree_oid: "tree9".to_owned(),
},
};
ShoreEvent::new(
EventType::RevisionCommitAssociated,
RevisionCommitAssociatedPayload::idempotency_key(&ru, commit_oid),
target_for(&ru, track),
writer_for(writer),
payload,
"2026-06-19T00:00:00Z",
)
.unwrap()
}
fn commit_withdraw_event(commit_oid: &str, writer: &str, track: &str) -> ShoreEvent {
let ru = revision();
let cid = build_commit_association_id(&ru, commit_oid).unwrap();
let wid = build_commit_withdrawal_id(&ru, &cid).unwrap();
let payload = RevisionCommitWithdrawnPayload {
commit_withdrawal_id: wid,
target: ReviewTargetRef::Revision {
revision_id: ru.clone(),
},
commit_association_id: cid.clone(),
};
ShoreEvent::new(
EventType::RevisionCommitWithdrawn,
RevisionCommitWithdrawnPayload::idempotency_key(&cid),
target_for(&ru, track),
writer_for(writer),
payload,
"2026-06-19T00:00:00Z",
)
.unwrap()
}
#[test]
fn same_edge_converges_across_writers() {
let a = commit_assoc_event("oid123", "alice", "author");
let b = commit_assoc_event("oid123", "bob", "reviewer");
assert_eq!(a.event_id, b.event_id, "event id is key-derived");
assert_eq!(
a.payload_hash, b.payload_hash,
"writer/track not in payload"
);
assert_ne!(
a.writer, b.writer,
"envelope writer differs — and that is fine"
);
assert_eq!(
event_set_hash_for_events([&a]).unwrap(),
event_set_hash_for_events([&b]).unwrap(),
"event-set contribution is identical"
);
}
#[test]
fn re_record_returns_existing_and_distinct_edge_is_a_new_member() {
let root = tempfile::tempdir().unwrap();
let store = EventStore::open(root.path().join(".shore/data"));
let a = commit_assoc_event("oid123", "alice", "author");
let b = commit_assoc_event("oid123", "bob", "reviewer");
let other = commit_assoc_event("oid999", "alice", "author");
assert_eq!(
store.record_event_once(&a).unwrap(),
EventWriteOutcome::Created
);
assert_eq!(
store.record_event_once(&b).unwrap(),
EventWriteOutcome::Existing,
"same edge by another writer converges to Existing"
);
assert_eq!(
store.record_event_once(&other).unwrap(),
EventWriteOutcome::Created,
"a distinct OID is a new member, not a conflict"
);
}
#[test]
fn withdrawal_is_separable_and_convergent() {
let a = commit_withdraw_event("oid123", "alice", "author");
let b = commit_withdraw_event("oid123", "bob", "reviewer");
assert_eq!(
a.event_id, b.event_id,
"withdrawal converges across writers"
);
assert_eq!(a.payload_hash, b.payload_hash, "ids-only payload converges");
let assoc = commit_assoc_event("oid123", "alice", "author");
assert_ne!(
a.idempotency_key, assoc.idempotency_key,
"withdrawal key never collides with the association key"
);
let root = tempfile::tempdir().unwrap();
let store = EventStore::open(root.path().join(".shore/data"));
assert_eq!(
store.record_event_once(&a).unwrap(),
EventWriteOutcome::Created
);
assert_eq!(
store.record_event_once(&b).unwrap(),
EventWriteOutcome::Existing
);
}
}