use chrono::{DateTime, Utc};
use uuid::Uuid;
use vti_common::audit::AuditKey;
use vti_common::error::AppError;
use vti_common::pagination::{Cursor, Paginated, paginate};
use vti_common::store::{Batch, KeyspaceHandle};
use crate::credentials::lifecycle::LifecycleEventKind;
use super::Relationship;
pub const RELATIONSHIPS_PREFIX: &[u8] = b"relationships:";
pub const RELATIONSHIPS_BY_DID_PREFIX: &[u8] = b"relationships_by_did:";
fn primary_key(id: Uuid) -> Vec<u8> {
let mut k = RELATIONSHIPS_PREFIX.to_vec();
k.extend_from_slice(id.to_string().as_bytes());
k
}
fn index_key(did: &str, id: Uuid) -> Vec<u8> {
let mut k = RELATIONSHIPS_BY_DID_PREFIX.to_vec();
k.extend_from_slice(did.as_bytes());
k.push(b':');
k.extend_from_slice(id.to_string().as_bytes());
k
}
fn decode(bytes: &[u8]) -> Result<Relationship, AppError> {
serde_json::from_slice(bytes)
.map_err(|e| AppError::Internal(format!("Relationship decode: {e}")))
}
pub async fn get_relationship(
primary: &KeyspaceHandle,
id: Uuid,
) -> Result<Option<Relationship>, AppError> {
let raw = primary.get_raw(primary_key(id)).await?;
match raw {
Some(bytes) => Ok(Some(decode(&bytes)?)),
None => Ok(None),
}
}
pub async fn store_relationship(
primary: &KeyspaceHandle,
index: &KeyspaceHandle,
rel: &Relationship,
) -> Result<(), AppError> {
primary
.insert(
String::from_utf8(primary_key(rel.id)).expect("ascii key"),
rel,
)
.await?;
let id_value = serde_json::json!({ "id": rel.id.to_string() });
index
.insert(
String::from_utf8(index_key(&rel.issuer_did, rel.id)).expect("ascii key"),
&id_value,
)
.await?;
if rel.subject_did != rel.issuer_did {
index
.insert(
String::from_utf8(index_key(&rel.subject_did, rel.id)).expect("ascii key"),
&id_value,
)
.await?;
}
Ok(())
}
pub async fn delete_relationship(
primary: &KeyspaceHandle,
index: &KeyspaceHandle,
id: Uuid,
) -> Result<(), AppError> {
if let Some(rel) = get_relationship(primary, id).await? {
let mut batch = Batch::new();
batch.remove(primary_key(id));
primary.apply_batch(batch).await?;
index.remove(index_key(&rel.issuer_did, id)).await?;
if rel.subject_did != rel.issuer_did {
index.remove(index_key(&rel.subject_did, id)).await?;
}
} else {
primary.remove(primary_key(id)).await?;
}
Ok(())
}
const UPDATE_ATTEMPTS: usize = 64;
async fn update_relationship_with<F>(
primary: &KeyspaceHandle,
id: Uuid,
mut modify: F,
) -> Result<Option<Relationship>, AppError>
where
F: FnMut(&mut Relationship) -> Result<bool, AppError>,
{
let key = primary_key(id);
for _ in 0..UPDATE_ATTEMPTS {
let Some(raw) = primary.get_raw(key.clone()).await? else {
return Ok(None);
};
let mut rel = decode(&raw)?;
if !modify(&mut rel)? {
return Ok(Some(rel));
}
let mut batch = Batch::new();
batch
.require_unchanged(key.clone(), raw)
.insert(key.clone(), &rel)?;
if primary.apply_batch(batch).await?.is_applied() {
return Ok(Some(rel));
}
}
Err(AppError::Conflict(format!(
"VRC {id} kept changing while its lifecycle was being recorded"
)))
}
pub async fn record_lifecycle_event(
primary: &KeyspaceHandle,
_index: &KeyspaceHandle,
id: Uuid,
kind: LifecycleEventKind,
now: DateTime<Utc>,
) -> Result<Relationship, AppError> {
update_relationship_with(primary, id, |rel| {
rel.lifecycle.record(kind.clone(), now)?;
Ok(true)
})
.await?
.ok_or_else(|| AppError::NotFound(format!("VRC {id} not found")))
}
pub async fn supersede_prior_edges(
primary: &KeyspaceHandle,
_index: &KeyspaceHandle,
issuer_did: &str,
subject_did: &str,
except_id: Uuid,
by_digest: &str,
now: DateTime<Utc>,
) -> Result<Vec<Uuid>, AppError> {
let mut superseded = Vec::new();
for rel in list_all(primary).await? {
if rel.id == except_id || rel.issuer_did != issuer_did || rel.subject_did != subject_did {
continue;
}
let mut recorded = false;
update_relationship_with(primary, rel.id, |rel| {
recorded = !rel.lifecycle.is_terminal();
if recorded {
rel.lifecycle.record(
LifecycleEventKind::Superseded {
by: by_digest.to_string(),
},
now,
)?;
}
Ok(recorded)
})
.await?;
if recorded {
superseded.push(rel.id);
}
}
Ok(superseded)
}
pub async fn find_by_hash(
primary: &KeyspaceHandle,
vrc_digest_multibase: &str,
) -> Result<Option<Relationship>, AppError> {
let pairs = primary
.prefix_iter_raw(RELATIONSHIPS_PREFIX.to_vec())
.await?;
for (_k, v) in pairs {
if let Ok(rel) = decode(&v)
&& rel.vrc_digest_multibase == vrc_digest_multibase
{
return Ok(Some(rel));
}
}
Ok(None)
}
pub async fn list_all(primary: &KeyspaceHandle) -> Result<Vec<Relationship>, AppError> {
let pairs = primary
.prefix_iter_raw(RELATIONSHIPS_PREFIX.to_vec())
.await?;
let mut out = Vec::with_capacity(pairs.len());
for (_k, v) in pairs {
match decode(&v) {
Ok(rel) => out.push(rel),
Err(e) => tracing::warn!(error = %e, "skipping unparseable relationship row"),
}
}
Ok(out)
}
pub async fn list_for_did(
primary: &KeyspaceHandle,
index: &KeyspaceHandle,
audit_key: &AuditKey,
did: &str,
cursor: Option<&Cursor>,
limit: usize,
) -> Result<Paginated<Relationship>, AppError> {
let mut index_prefix = RELATIONSHIPS_BY_DID_PREFIX.to_vec();
index_prefix.extend_from_slice(did.as_bytes());
index_prefix.push(b':');
let mut idx_pairs = index.prefix_iter_raw(index_prefix).await?;
idx_pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut hydrated: Vec<(Vec<u8>, Vec<u8>)> = Vec::with_capacity(idx_pairs.len());
for (idx_key, _idx_val) in idx_pairs {
let key_str = match std::str::from_utf8(&idx_key) {
Ok(s) => s,
Err(_) => continue,
};
let Some(id_str) = key_str.rsplit(':').next() else {
continue;
};
let Ok(id) = Uuid::parse_str(id_str) else {
continue;
};
if let Some(raw) = primary.get_raw(primary_key(id)).await? {
match decode(&raw) {
Ok(rel) if rel.issuer_did == did || rel.subject_did == did => {
hydrated.push((idx_key, raw));
}
_ => {}
}
}
}
let snapshot_id: u64 = hydrated.len() as u64;
paginate(hydrated, cursor, limit, &audit_key.key, snapshot_id, decode)
}
pub async fn issuer_counterparties_besides(
primary: &KeyspaceHandle,
index: &KeyspaceHandle,
issuer_did: &str,
except_subject: &str,
) -> Result<Vec<String>, AppError> {
let mut index_prefix = RELATIONSHIPS_BY_DID_PREFIX.to_vec();
index_prefix.extend_from_slice(issuer_did.as_bytes());
index_prefix.push(b':');
let mut others = Vec::new();
for (idx_key, _) in index.prefix_iter_raw(index_prefix).await? {
let Ok(key_str) = std::str::from_utf8(&idx_key) else {
continue;
};
let Some(id_str) = key_str.rsplit(':').next() else {
continue;
};
let Ok(id) = Uuid::parse_str(id_str) else {
continue;
};
let Some(raw) = primary.get_raw(primary_key(id)).await? else {
continue;
};
if let Ok(rel) = decode(&raw)
&& rel.issuer_did == issuer_did
&& rel.subject_did != except_subject
&& !others.contains(&rel.subject_did)
{
others.push(rel.subject_did);
}
}
Ok(others)
}
pub async fn counterparties_in_force(
primary: &KeyspaceHandle,
index: &KeyspaceHandle,
did: &str,
now: chrono::DateTime<chrono::Utc>,
) -> Result<std::collections::BTreeSet<String>, AppError> {
let mut index_prefix = RELATIONSHIPS_BY_DID_PREFIX.to_vec();
index_prefix.extend_from_slice(did.as_bytes());
index_prefix.push(b':');
let mut out = std::collections::BTreeSet::new();
for (idx_key, _) in index.prefix_iter_raw(index_prefix).await? {
let Ok(key_str) = std::str::from_utf8(&idx_key) else {
continue;
};
let Some(Ok(id)) = key_str.rsplit(':').next().map(Uuid::parse_str) else {
continue;
};
let Some(raw) = primary.get_raw(primary_key(id)).await? else {
continue;
};
let Ok(rel) = decode(&raw) else {
continue;
};
if !matches!(rel.in_force_at(now), super::InForce::Yes) {
continue;
}
if rel.issuer_did == did {
out.insert(rel.subject_did);
} else if rel.subject_did == did {
out.insert(rel.issuer_did);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use vti_common::audit::AuditKeyStore;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn temp_kss() -> (KeyspaceHandle, KeyspaceHandle, AuditKey, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let primary = store.keyspace("relationships").unwrap();
let index = store.keyspace("relationships_by_did").unwrap();
let audit_key_ks = store.keyspace("audit_key").unwrap();
let key_store = AuditKeyStore::new(audit_key_ks);
key_store.ensure_initial(&[0xAB; 32]).await.unwrap();
let audit_key = key_store.active().await.unwrap();
(primary, index, audit_key, dir)
}
fn fresh(issuer: &str, subject: &str) -> Relationship {
let id = Uuid::new_v4();
Relationship {
id,
issuer_did: issuer.into(),
subject_did: subject.into(),
vrc_jsonld: serde_json::json!({
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": issuer,
"issuerScope": "pairwise",
"credentialSubject": { "id": subject }
}),
vrc_digest_multibase: format!("{:x}", id.as_u128()),
created_at: Utc::now(),
persona: None,
lifecycle: Default::default(),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn racing_lifecycle_events_are_never_lost() {
let (primary, index, _audit, _dir) = temp_kss().await;
for _ in 0..40 {
let rel = fresh("did:key:zA", "did:key:zB");
store_relationship(&primary, &index, &rel).await.unwrap();
let now = Utc::now();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(8));
let mut tasks = Vec::new();
for i in 0..8 {
let (primary, index, barrier) = (primary.clone(), index.clone(), barrier.clone());
tasks.push(tokio::spawn(async move {
barrier.wait().await;
record_lifecycle_event(
&primary,
&index,
rel.id,
LifecycleEventKind::Withdrawn {
reason: Some(format!("w{i}")),
},
now,
)
.await
.is_ok()
}));
}
let mut ok = 0;
for t in tasks {
ok += t.await.unwrap() as usize;
}
let stored = get_relationship(&primary, rel.id).await.unwrap().unwrap();
assert_eq!(ok, 1, "{ok} terminal events reported recorded");
assert_eq!(stored.lifecycle.events().len(), 1);
}
}
#[tokio::test]
async fn round_trip() {
let (primary, index, _audit, _dir) = temp_kss().await;
let rel = fresh("did:key:zA", "did:key:zB");
store_relationship(&primary, &index, &rel).await.unwrap();
let got = get_relationship(&primary, rel.id).await.unwrap().unwrap();
assert_eq!(got, rel);
}
#[tokio::test]
async fn rows_written_before_the_persona_field_still_decode() {
let raw = serde_json::json!({
"id": Uuid::new_v4().to_string(),
"issuerDid": "did:key:zA",
"subjectDid": "did:key:zB",
"vrcJsonld": { "type": ["VerifiableCredential"] },
"vrcDigestMultibase": "deadbeef",
"createdAt": "2026-01-01T00:00:00Z",
});
let rel = decode(raw.to_string().as_bytes()).expect("pre-#1067 row must decode");
assert!(rel.persona.is_none());
}
#[tokio::test]
async fn list_all_returns_every_edge() {
let (primary, index, _audit, _dir) = temp_kss().await;
let r1 = fresh("did:key:zA", "did:key:zB");
let r2 = fresh("did:key:zB", "did:key:zC");
store_relationship(&primary, &index, &r1).await.unwrap();
store_relationship(&primary, &index, &r2).await.unwrap();
let all = list_all(&primary).await.unwrap();
assert_eq!(all.len(), 2);
let ids: std::collections::HashSet<_> = all.iter().map(|r| r.id).collect();
assert!(ids.contains(&r1.id) && ids.contains(&r2.id));
}
#[tokio::test]
async fn delete_clears_primary_and_index() {
let (primary, index, _audit, _dir) = temp_kss().await;
let rel = fresh("did:key:zA", "did:key:zB");
store_relationship(&primary, &index, &rel).await.unwrap();
delete_relationship(&primary, &index, rel.id).await.unwrap();
assert!(get_relationship(&primary, rel.id).await.unwrap().is_none());
let pairs = index
.prefix_iter_raw(RELATIONSHIPS_BY_DID_PREFIX.to_vec())
.await
.unwrap();
assert!(pairs.is_empty());
}
#[tokio::test]
async fn list_for_issuer_returns_issued() {
let (primary, index, audit, _dir) = temp_kss().await;
let r1 = fresh("did:key:zA", "did:key:zB");
let r2 = fresh("did:key:zA", "did:key:zC");
let r3 = fresh("did:key:zX", "did:key:zY"); for r in [&r1, &r2, &r3] {
store_relationship(&primary, &index, r).await.unwrap();
}
let page = list_for_did(&primary, &index, &audit, "did:key:zA", None, 10)
.await
.unwrap();
assert_eq!(page.items.len(), 2);
let dids: Vec<_> = page.items.iter().map(|r| r.id).collect();
assert!(dids.contains(&r1.id));
assert!(dids.contains(&r2.id));
}
#[tokio::test]
async fn list_for_subject_returns_received() {
let (primary, index, audit, _dir) = temp_kss().await;
let r1 = fresh("did:key:zA", "did:key:zSubject");
let r2 = fresh("did:key:zB", "did:key:zSubject");
let r3 = fresh("did:key:zC", "did:key:zOther");
for r in [&r1, &r2, &r3] {
store_relationship(&primary, &index, r).await.unwrap();
}
let page = list_for_did(&primary, &index, &audit, "did:key:zSubject", None, 10)
.await
.unwrap();
assert_eq!(page.items.len(), 2);
}
#[tokio::test]
async fn list_paginates_with_cursor() {
let (primary, index, audit, _dir) = temp_kss().await;
for _ in 0..10 {
let rel = fresh("did:key:zHub", &format!("did:key:z{}", Uuid::new_v4()));
store_relationship(&primary, &index, &rel).await.unwrap();
}
let page1 = list_for_did(&primary, &index, &audit, "did:key:zHub", None, 3)
.await
.unwrap();
assert_eq!(page1.items.len(), 3);
let cursor_str = page1.next_cursor.as_ref().expect("more pages");
let cursor = Cursor::decode(cursor_str, &audit.key).expect("decode cursor");
let page2 = list_for_did(&primary, &index, &audit, "did:key:zHub", Some(&cursor), 3)
.await
.unwrap();
assert_eq!(page2.items.len(), 3);
let ids1: std::collections::HashSet<_> = page1.items.iter().map(|r| r.id).collect();
for r in &page2.items {
assert!(!ids1.contains(&r.id));
}
}
#[tokio::test]
async fn find_by_hash_returns_existing_row() {
let (primary, index, _audit, _dir) = temp_kss().await;
let mut rel = fresh("did:key:zA", "did:key:zB");
rel.vrc_digest_multibase = "deadbeef".into();
store_relationship(&primary, &index, &rel).await.unwrap();
let got = find_by_hash(&primary, "deadbeef").await.unwrap();
assert_eq!(got.map(|r| r.id), Some(rel.id));
let miss = find_by_hash(&primary, "feedface").await.unwrap();
assert!(miss.is_none());
}
#[tokio::test]
async fn list_does_not_leak_colon_prefix_colliding_did() {
let (primary, index, audit, _dir) = temp_kss().await;
let short = fresh("did:webvh:s:h", "did:key:zPeer");
let long = fresh("did:webvh:s:h:x", "did:key:zPeer2");
store_relationship(&primary, &index, &short).await.unwrap();
store_relationship(&primary, &index, &long).await.unwrap();
let page = list_for_did(&primary, &index, &audit, "did:webvh:s:h", None, 10)
.await
.unwrap();
let ids: Vec<_> = page.items.iter().map(|r| r.id).collect();
assert_eq!(ids, vec![short.id], "must return only the exact DID's edge");
assert!(
!ids.contains(&long.id),
"colon-extended DID's edge must not leak"
);
let page_long = list_for_did(&primary, &index, &audit, "did:webvh:s:h:x", None, 10)
.await
.unwrap();
assert_eq!(
page_long.items.iter().map(|r| r.id).collect::<Vec<_>>(),
vec![long.id]
);
}
#[tokio::test]
async fn a_recorded_event_survives_the_round_trip() {
let (primary, index, _audit, _dir) = temp_kss().await;
let rel = fresh("did:key:zA", "did:key:zB");
store_relationship(&primary, &index, &rel).await.unwrap();
let now = Utc::now();
let updated = record_lifecycle_event(
&primary,
&index,
rel.id,
LifecycleEventKind::Suspended {
reason: Some("under review".into()),
},
now,
)
.await
.unwrap();
assert_eq!(updated.lifecycle.events().len(), 1);
let stored = get_relationship(&primary, rel.id).await.unwrap().unwrap();
assert_eq!(stored.lifecycle, updated.lifecycle);
assert!(!stored.in_force_at(now).is_in_force());
}
#[tokio::test]
async fn an_illegal_transition_is_refused_and_writes_nothing() {
let (primary, index, _audit, _dir) = temp_kss().await;
let rel = fresh("did:key:zA", "did:key:zB");
store_relationship(&primary, &index, &rel).await.unwrap();
let now = Utc::now();
let err = record_lifecycle_event(
&primary,
&index,
rel.id,
LifecycleEventKind::Restored { reason: None },
now,
)
.await
.expect_err("nothing is suspended");
assert!(matches!(err, AppError::Conflict(_)), "{err:?}");
let stored = get_relationship(&primary, rel.id).await.unwrap().unwrap();
assert!(stored.lifecycle.is_empty(), "a refusal must not append");
}
#[tokio::test]
async fn recording_against_an_absent_edge_is_a_404() {
let (primary, index, _audit, _dir) = temp_kss().await;
let err = record_lifecycle_event(
&primary,
&index,
Uuid::new_v4(),
LifecycleEventKind::Suspended { reason: None },
Utc::now(),
)
.await
.expect_err("no such edge");
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
}
#[tokio::test]
async fn a_reissue_supersedes_the_earlier_edge_to_the_same_counterparty() {
let (primary, index, _audit, _dir) = temp_kss().await;
let old = fresh("did:key:zA", "did:key:zB");
let replacement = fresh("did:key:zA", "did:key:zB");
let other_subject = fresh("did:key:zA", "did:key:zC");
let reciprocal = fresh("did:key:zB", "did:key:zA");
for r in [&old, &replacement, &other_subject, &reciprocal] {
store_relationship(&primary, &index, r).await.unwrap();
}
let now = Utc::now();
let displaced = supersede_prior_edges(
&primary,
&index,
"did:key:zA",
"did:key:zB",
replacement.id,
"zNewDigest",
now,
)
.await
.unwrap();
assert_eq!(displaced, vec![old.id]);
let stored = get_relationship(&primary, old.id).await.unwrap().unwrap();
assert!(!stored.in_force_at(now).is_in_force());
for untouched in [replacement.id, other_subject.id, reciprocal.id] {
let r = get_relationship(&primary, untouched)
.await
.unwrap()
.unwrap();
assert!(
r.lifecycle.is_empty(),
"only the edge to the same counterparty is displaced"
);
}
}
#[tokio::test]
async fn supersession_skips_an_edge_that_is_already_terminal() {
let (primary, index, _audit, _dir) = temp_kss().await;
let mut already = fresh("did:key:zA", "did:key:zB");
let earlier = Utc::now() - chrono::Duration::days(1);
already
.lifecycle
.record(
LifecycleEventKind::Superseded {
by: "zFirstReplacement".into(),
},
earlier,
)
.unwrap();
store_relationship(&primary, &index, &already)
.await
.unwrap();
let displaced = supersede_prior_edges(
&primary,
&index,
"did:key:zA",
"did:key:zB",
Uuid::new_v4(),
"zSecondReplacement",
Utc::now(),
)
.await
.expect("a terminal row must not fail the publish");
assert!(displaced.is_empty());
let stored = get_relationship(&primary, already.id)
.await
.unwrap()
.unwrap();
assert_eq!(
stored.lifecycle.events().len(),
1,
"the original displacement record is not overwritten"
);
}
#[tokio::test]
async fn self_loop_writes_one_index_entry() {
let (primary, index, audit, _dir) = temp_kss().await;
let rel = fresh("did:key:zSelf", "did:key:zSelf");
store_relationship(&primary, &index, &rel).await.unwrap();
let page = list_for_did(&primary, &index, &audit, "did:key:zSelf", None, 10)
.await
.unwrap();
assert_eq!(page.items.len(), 1, "self-edge must list once, not twice");
}
}