use crate::db::trees::{Method, Operation, Transaction, Tree};
use crate::errors::AtomicResult;
use crate::{commit::CommitResponse, Db};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EnvelopeRetention {
#[default]
Latest,
All,
}
impl EnvelopeRetention {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"latest" => Some(Self::Latest),
"all" | "full" => Some(Self::All),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Latest => "latest",
Self::All => "all",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct StoredEnvelope {
pub subject: String,
pub created_at: i64,
pub signature: String,
pub json: String,
}
impl StoredEnvelope {
pub fn commit_id(&self) -> String {
format!("did:ad:commit:{}", self.signature)
}
pub fn is_destroy(&self) -> bool {
serde_json::from_str::<serde_json::Value>(&self.json)
.ok()
.and_then(|v| v.get(crate::urls::DESTROY).and_then(|d| d.as_bool()))
.unwrap_or(false)
}
}
fn prefix(subject: &str) -> Vec<u8> {
let pure = crate::Subject::from_raw(subject, None).pure_id();
let mut key = Vec::with_capacity(pure.len() + 1);
key.extend_from_slice(pure.as_bytes());
key.push(0);
key
}
fn key(subject: &str, created_at: i64, signature: &str) -> Vec<u8> {
let mut key = prefix(subject);
key.extend_from_slice(&(created_at.max(0) as u64).to_be_bytes());
key.push(0);
key.extend_from_slice(signature.as_bytes());
key
}
fn decode(key: &[u8], value: Vec<u8>) -> Option<StoredEnvelope> {
let subject_end = key.iter().position(|b| *b == 0)?;
let subject = std::str::from_utf8(&key[..subject_end]).ok()?.to_string();
let rest = &key[subject_end + 1..];
if rest.len() < 9 || rest[8] != 0 {
return None;
}
let created_at = u64::from_be_bytes(rest[..8].try_into().ok()?) as i64;
let signature = std::str::from_utf8(&rest[9..]).ok()?.to_string();
let json = String::from_utf8(value).ok()?;
Some(StoredEnvelope {
subject,
created_at,
signature,
json,
})
}
pub fn record_ops(
store: &Db,
response: &CommitResponse,
transaction: &mut Transaction,
) -> AtomicResult<()> {
let Some(signature) = response.commit.signature.as_deref() else {
return Ok(());
};
let subject = response.commit.subject.as_str();
let json = response.commit_resource.to_json_ad(None)?;
let new_key = key(subject, response.commit.created_at, signature);
if store.envelope_retention() == EnvelopeRetention::Latest {
for existing in store.kv.scan_prefix(Tree::Envelopes, &prefix(subject)) {
let (old_key, _) = existing?;
if old_key != new_key {
transaction.push(Operation {
tree: Tree::Envelopes,
method: Method::Delete,
key: old_key,
val: None,
});
}
}
}
transaction.push(Operation {
tree: Tree::Envelopes,
method: Method::Insert,
key: new_key,
val: Some(json.into_bytes()),
});
Ok(())
}
pub fn envelopes(store: &Db, subject: &str) -> Vec<StoredEnvelope> {
store
.kv
.scan_prefix(Tree::Envelopes, &prefix(subject))
.filter_map(|entry| entry.ok())
.filter_map(|(k, v)| decode(&k, v))
.collect()
}
pub fn latest_envelope(store: &Db, subject: &str) -> Option<StoredEnvelope> {
envelopes(store, subject).into_iter().last()
}
pub fn clear_envelopes(store: &Db, subject: &str) {
for (k, _) in store
.kv
.scan_prefix(Tree::Envelopes, &prefix(subject))
.flatten()
{
let _ = store.kv.remove(Tree::Envelopes, &k);
}
}
fn is_genesis_carrier(token: &str) -> bool {
token.starts_with("did:ad:agent:")
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Attribution {
pub signer: String,
pub created_at: i64,
pub signature: String,
pub verified: bool,
pub tokens: Vec<String>,
pub destroy: bool,
pub genesis: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct HistoryAttribution {
pub subject: String,
pub retention: &'static str,
pub attributions: Vec<Attribution>,
pub complete: bool,
}
pub async fn attribute_history(store: &Db, subject: &str) -> AtomicResult<HistoryAttribution> {
let retention = store.envelope_retention().as_str();
let mut attributions: Vec<Attribution> = Vec::new();
let pure = crate::Subject::from_raw(subject, None).pure_id();
let stored_doc = store
.kv
.get(Tree::LoroSnapshots, pure.as_bytes())
.ok()
.flatten()
.and_then(|bytes| crate::loro::AtomicLoroDoc::from_snapshot(&bytes).ok());
for envelope in envelopes(store, subject) {
let resource = crate::parse::parse_json_ad_commit_resource(&envelope.json, store).await?;
let commit = crate::commit::Commit::from_resource(resource)?;
let verified = commit.validate_signature(store).await.is_ok();
let is_genesis = commit.is_genesis == Some(true);
let mut tokens = Vec::new();
if let (Some(update), Some(doc)) = (commit.loro_update.as_deref(), stored_doc.as_ref()) {
if let Ok((start, end)) = crate::loro::AtomicLoroDoc::update_range(update) {
for message in doc.change_messages_in(&start, &end) {
if is_genesis_carrier(&message) && !is_genesis {
continue;
}
let claimed = attributions.iter().any(|a| a.tokens.contains(&message));
if !claimed && !tokens.contains(&message) {
tokens.push(message);
}
}
}
}
attributions.push(Attribution {
signer: commit.signer.to_string(),
created_at: commit.created_at,
signature: envelope.signature.clone(),
verified,
tokens,
destroy: commit.destroy.unwrap_or(false),
genesis: is_genesis,
});
}
let stored_tokens: Option<Vec<String>> = stored_doc.as_ref().map(|doc| {
doc.get_history()
.into_iter()
.filter_map(|change| change.message)
.collect()
});
let complete = match stored_tokens {
Some(tokens) => {
!attributions.is_empty()
&& tokens
.iter()
.filter(|token| !is_genesis_carrier(token))
.all(|token| {
attributions
.iter()
.any(|a| a.verified && a.tokens.contains(token))
})
}
None => false,
};
Ok(HistoryAttribution {
subject: pure,
retention,
attributions,
complete,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::ForAgent;
use crate::sync::engine::{ingest_commit_json, CommitIngestOpts};
use crate::{urls, Storelike, Value};
async fn signed_edit(db: &Db, subject: &crate::Subject, name: &str) {
let mut resource = db.get_resource(subject).await.unwrap();
resource
.set(urls::NAME.into(), Value::String(name.into()), db)
.await
.unwrap();
let response = resource.save_locally(db).await.unwrap();
assert!(response.commit.signature.is_some(), "save_locally signs");
}
async fn child(db: &Db, drive: &str) -> crate::Subject {
let subject = db
.create_resource(
urls::CLASS,
drive,
"Doc",
Some(vec![
(urls::DESCRIPTION, Value::String("d".into())),
(urls::SHORTNAME, Value::Slug("doc".into())),
]),
)
.await
.unwrap();
crate::Subject::from_raw(&subject, None)
}
#[tokio::test]
async fn latest_retention_keeps_one_envelope_per_resource() {
let db = Db::init_temp("envelopes_latest").await.unwrap();
let (_alice, drive) = db.setup("Alice").await.unwrap();
let subject = child(&db, &drive).await;
assert_eq!(
envelopes(&db, subject.as_str()).len(),
1,
"create_resource signs a genesis"
);
for name in ["one", "two"] {
signed_edit(&db, &subject, name).await;
}
let kept = envelopes(&db, subject.as_str());
assert_eq!(kept.len(), 1, "Latest keeps only the newest envelope");
let latest = latest_envelope(&db, subject.as_str()).unwrap();
assert_eq!(kept[0], latest);
let stamp = db
.get_resource(&subject)
.await
.unwrap()
.get(urls::LAST_COMMIT)
.unwrap()
.to_string();
assert_eq!(latest.commit_id(), stamp, "the kept envelope is lastCommit");
}
#[tokio::test]
async fn all_retention_keeps_every_envelope_in_time_order() {
let db = Db::init_temp("envelopes_all").await.unwrap();
db.set_envelope_retention(EnvelopeRetention::All);
let (_alice, drive) = db.setup("Alice").await.unwrap();
let subject = child(&db, &drive).await;
for name in ["one", "two", "three"] {
signed_edit(&db, &subject, name).await;
}
let kept = envelopes(&db, subject.as_str());
assert_eq!(kept.len(), 4, "genesis plus three edits");
assert!(kept.windows(2).all(|w| w[0].created_at <= w[1].created_at));
let stamp = db
.get_resource(&subject)
.await
.unwrap()
.get(urls::LAST_COMMIT)
.unwrap()
.to_string();
assert_eq!(kept.last().unwrap().commit_id(), stamp);
}
#[tokio::test]
async fn envelopes_are_not_resources_or_query_hits() {
let db = Db::init_temp("envelopes_not_indexed").await.unwrap();
let (_alice, drive) = db.setup("Alice").await.unwrap();
let subject = child(&db, &drive).await;
let latest = latest_envelope(&db, subject.as_str()).unwrap();
assert!(!db.has_resource_locally(&format!("envelope:{}", latest.signature)));
let mut query = crate::storelike::Query::new_prop_val(urls::SIGNER, "did:ad:agent:nobody");
query.limit = Some(10);
assert_eq!(db.query(&query).await.unwrap().count, 0);
}
#[tokio::test]
async fn history_attribution_maps_verified_signers_onto_loro_tokens() {
let db = Db::init_temp("envelopes_attribution").await.unwrap();
db.set_envelope_retention(EnvelopeRetention::All);
let (alice, drive) = db.setup("Alice").await.unwrap();
let subject = child(&db, &drive).await;
signed_edit(&db, &subject, "edited").await;
let report = attribute_history(&db, subject.as_str()).await.unwrap();
assert_eq!(report.retention, "all");
assert_eq!(report.attributions.len(), 2);
assert!(report.attributions.iter().all(|a| a.verified));
assert!(report.attributions[0].genesis);
assert!(!report.attributions[1].genesis);
assert!(report
.attributions
.iter()
.all(|a| a.signer == alice.subject));
assert!(
report.complete,
"replaying the retained envelopes must reproduce the stored oplog"
);
let resource = db.get_resource(&subject).await.unwrap();
let versions = crate::history::versions(&resource).unwrap();
for version in versions.iter().filter_map(|v| v.message.clone()) {
let owners = report
.attributions
.iter()
.filter(|a| a.tokens.contains(&version))
.count();
assert_eq!(owners, 1, "token {version} must map to one signer");
}
}
#[tokio::test]
async fn tampered_envelope_is_unverified_and_history_incomplete() {
let db = Db::init_temp("envelopes_tampered").await.unwrap();
db.set_envelope_retention(EnvelopeRetention::All);
let (_alice, drive) = db.setup("Alice").await.unwrap();
let subject = child(&db, &drive).await;
signed_edit(&db, &subject, "edited").await;
let rows = envelopes(&db, subject.as_str());
let last = rows.last().unwrap();
let mut broken: serde_json::Value = serde_json::from_str(&last.json).unwrap();
broken[urls::SIGNATURE] = serde_json::Value::String("AAAA".into());
db.kv
.insert(
Tree::Envelopes,
&key(subject.as_str(), last.created_at, &last.signature),
broken.to_string().as_bytes(),
)
.unwrap();
let report = attribute_history(&db, subject.as_str()).await.unwrap();
assert!(report.attributions[0].verified);
assert!(!report.attributions[1].verified);
assert!(!report.complete);
}
#[tokio::test]
async fn latest_retention_under_a_second_writer_keeps_the_newest_signer() {
let db = Db::init_temp("envelopes_two_writers").await.unwrap();
let (alice, drive) = db.setup("Alice").await.unwrap();
let bob = db.create_agent(Some("Bob")).await.unwrap();
let subject = child(&db, &drive).await;
let mut resource = db.get_resource(&subject).await.unwrap();
resource
.set_unsafe(
urls::WRITE.into(),
Value::ResourceArray(vec![
alice.subject.to_string().into(),
bob.subject.to_string().into(),
]),
)
.unwrap();
db.add_resource_opts(&resource, false, true, true)
.await
.unwrap();
db.set_default_agent(bob.clone());
signed_edit(&db, &subject, "by bob").await;
db.set_default_agent(alice.clone());
let report = attribute_history(&db, subject.as_str()).await.unwrap();
assert_eq!(report.attributions.len(), 1);
assert_eq!(report.attributions[0].signer, bob.subject.to_string());
assert!(report.attributions[0].verified);
assert!(
!report.attributions[0]
.tokens
.iter()
.any(|t| t.starts_with("did:ad:agent:")),
"a snapshot-carrying edit must not be credited with the genesis change"
);
assert!(
report.complete,
"the genesis is proven by its certificate; the only other signed change is Bob's"
);
}
#[tokio::test]
async fn all_retention_credits_each_writer_with_their_own_change() {
let db = Db::init_temp("envelopes_two_writers_all").await.unwrap();
db.set_envelope_retention(EnvelopeRetention::All);
let (alice, drive) = db.setup("Alice").await.unwrap();
let bob = db.create_agent(Some("Bob")).await.unwrap();
let subject = child(&db, &drive).await;
let mut resource = db.get_resource(&subject).await.unwrap();
resource
.set_unsafe(
urls::WRITE.into(),
Value::ResourceArray(vec![
alice.subject.to_string().into(),
bob.subject.to_string().into(),
]),
)
.unwrap();
db.add_resource_opts(&resource, false, true, true)
.await
.unwrap();
signed_edit(&db, &subject, "by alice").await;
db.set_default_agent(bob.clone());
signed_edit(&db, &subject, "by bob").await;
db.set_default_agent(alice.clone());
let report = attribute_history(&db, subject.as_str()).await.unwrap();
assert!(report.complete);
let signers: Vec<&str> = report
.attributions
.iter()
.map(|a| a.signer.as_str())
.collect();
assert_eq!(
signers,
vec![
alice.subject.as_str(),
alice.subject.as_str(),
bob.subject.as_str()
],
"genesis, Alice's edit, Bob's edit"
);
assert!(report.attributions[0].genesis);
assert_eq!(report.attributions[1].tokens.len(), 1);
assert_eq!(report.attributions[2].tokens.len(), 1);
assert_ne!(report.attributions[1].tokens, report.attributions[2].tokens);
let stored = db.get_resource(&subject).await.unwrap();
let versions = crate::history::versions(&stored).unwrap();
for token in versions.iter().filter_map(|v| v.message.clone()) {
let owners = report
.attributions
.iter()
.filter(|a| a.tokens.contains(&token))
.count();
assert_eq!(owners, 1, "token {token} must map to exactly one signer");
}
}
#[tokio::test]
async fn browser_style_delta_envelope_is_attributed_by_its_token() {
let db = Db::init_temp("envelopes_delta").await.unwrap();
db.set_envelope_retention(EnvelopeRetention::All);
let (alice, drive) = db.setup("Alice").await.unwrap();
let subject = child(&db, &drive).await;
let resource = db.get_resource(&subject).await.unwrap();
let snapshot = resource.materialized_state().expect("stored state");
let base_vv = crate::loro::AtomicLoroDoc::from_snapshot(&snapshot)
.unwrap()
.oplog_vv();
let client_doc = crate::loro::AtomicLoroDoc::from_snapshot(&snapshot).unwrap();
client_doc
.set_property(urls::NAME, &Value::String("delta edit".into()))
.unwrap();
client_doc.commit_with_message("c-01test-delta-token");
let delta = client_doc.export_updates_since(&base_vv);
assert!(!delta.is_empty());
let mut builder = crate::commit::CommitBuilder::new(subject.clone());
builder.set_loro_update(delta);
let commit = builder.sign(&alice, &db, &resource).await.unwrap();
let json = commit
.into_resource(&db)
.await
.unwrap()
.to_json_ad(None)
.unwrap();
ingest_commit_json(&db, &json, &CommitIngestOpts::peer())
.await
.expect("the delta applies");
let report = attribute_history(&db, subject.as_str()).await.unwrap();
let last = report.attributions.last().unwrap();
assert!(last.verified);
assert_eq!(
last.tokens,
vec!["c-01test-delta-token".to_string()],
"the delta's own change token is credited to its envelope"
);
assert!(report.complete, "{report:?}");
let stored = db.get_resource(&subject).await.unwrap();
assert!(crate::history::versions(&stored)
.unwrap()
.iter()
.any(|v| v.message.as_deref() == Some("c-01test-delta-token")));
}
#[tokio::test]
async fn destroy_envelope_is_the_latest_row_of_a_destroyed_subject() {
let db = Db::init_temp("envelopes_destroy").await.unwrap();
let (alice, drive) = db.setup("Alice").await.unwrap();
let subject = child(&db, &drive).await;
let resource = db.get_resource(&subject).await.unwrap();
let mut builder = crate::commit::CommitBuilder::new(subject.clone());
builder.destroy(true);
let commit = builder.sign(&alice, &db, &resource).await.unwrap();
let json = commit
.into_resource(&db)
.await
.unwrap()
.to_json_ad(None)
.unwrap();
ingest_commit_json(&db, &json, &CommitIngestOpts::peer())
.await
.unwrap();
let latest = latest_envelope(&db, subject.as_str()).unwrap();
assert!(latest.is_destroy());
assert_eq!(
crate::sync::tombstones::destroy_envelope(&db, subject.as_str()).as_deref(),
Some(latest.json.as_str()),
"the tombstone's envelope is the envelope tree's latest row"
);
assert!(crate::sync::tombstones::is_tombstoned(
&db,
subject.as_str()
));
let _ = ForAgent::Public;
}
}