use exo_core::{DeterministicMap, Did, Hash256, Timestamp, hash::hash_structured};
use serde::{Deserialize, Serialize};
use crate::{bailment::BailmentType, error::ConsentError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ClauseCategory {
DataCustody,
ProcessingRights,
BreachRemedies,
LiabilityCaps,
DisputeResolution,
Termination,
Jurisdiction,
Indemnification,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Clause {
pub id: String,
pub category: ClauseCategory,
pub title: String,
pub body: String,
pub required: bool,
pub jurisdiction: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContractTemplate {
pub id: String,
pub name: String,
pub bailment_type: BailmentType,
pub clauses: Vec<Clause>,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContractParams {
pub bailor_name: String,
pub bailee_name: String,
pub bailor_did: Did,
pub bailee_did: Did,
pub effective_date: Timestamp,
pub expiry_date: Option<Timestamp>,
pub jurisdiction: String,
pub data_classification: DataClassification,
pub liability_cap_bps: u64,
pub custom_params: DeterministicMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DataClassification {
Public,
Internal,
Confidential,
Restricted,
Regulated,
}
impl std::fmt::Display for DataClassification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Public => write!(f, "Public"),
Self::Internal => write!(f, "Internal"),
Self::Confidential => write!(f, "Confidential"),
Self::Restricted => write!(f, "Restricted"),
Self::Regulated => write!(f, "Regulated"),
}
}
}
impl DataClassification {
fn custody_obligations(self) -> &'static str {
match self {
Self::Public => {
"Public data may be stored with baseline integrity controls and publication-safe provenance tracking."
}
Self::Internal => {
"Internal data requires organization-scoped access controls and non-public distribution records."
}
Self::Confidential => {
"Confidential data requires least-privilege access, encrypted storage, encrypted transfer, and access logging."
}
Self::Restricted => {
"Restricted data requires documented need-to-know approval, segregated storage, encrypted transfer, and dual-control access for export."
}
Self::Regulated => {
"Regulated data requires statutory control mapping, jurisdiction-specific handling, audit-ready access logs, and retention policy enforcement."
}
}
}
fn processing_obligations(self) -> &'static str {
match self {
Self::Public => {
"Public data processing is limited to authorized use, attribution preservation, and integrity checks."
}
Self::Internal => {
"Internal data processing is limited to personnel, services, and agents operating under the bailee's internal authorization boundary."
}
Self::Confidential => {
"Confidential data processing requires purpose-bound authorization and prohibits secondary use without signed amendment."
}
Self::Restricted => {
"Restricted data processing is limited to named workflows and named operators; sub-processing requires explicit bailor approval."
}
Self::Regulated => {
"Regulated data processing is limited to enumerated legal bases and auditable processing records."
}
}
}
fn breach_notice_obligations(self) -> &'static str {
match self {
Self::Public => {
"Public classification breaches require notice within 10 business days when integrity or attribution is affected."
}
Self::Internal => {
"Internal classification breaches require notice within 5 business days."
}
Self::Confidential => {
"Confidential classification breaches require notice within 72 hours."
}
Self::Restricted => {
"Restricted classification breaches require notice within 24 hours."
}
Self::Regulated => {
"Regulated classification breaches require notice within the shortest applicable legal window, not exceeding 24 hours."
}
}
}
fn liability_obligations(self) -> &'static str {
match self {
Self::Public => {
"Public data liability remains limited to integrity, availability, and attribution failures."
}
Self::Internal => {
"Internal data liability includes unauthorized internal disclosure and unauthorized retention."
}
Self::Confidential => {
"Confidential data liability includes unauthorized disclosure, unauthorized processing, and control failure."
}
Self::Restricted => {
"Restricted data liability includes unauthorized access, export, delegation, or segregation failure."
}
Self::Regulated => {
"Regulated data liability includes regulatory reporting failure, unlawful processing, and retention violation."
}
}
}
fn termination_obligations(self) -> &'static str {
match self {
Self::Public => {
"Public data must be returned, deleted, or left published according to the bailor's written instruction."
}
Self::Internal => {
"Internal data must be returned or deleted with an internal access revocation record."
}
Self::Confidential => {
"Confidential data must be returned or destroyed with verifiable destruction evidence."
}
Self::Restricted => {
"Restricted data must be quarantined immediately on termination until return or destruction is receipt-backed."
}
Self::Regulated => {
"Regulated data must follow the governing retention schedule and produce a compliance evidence package on termination."
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComposedContract {
pub id: String,
pub template_id: String,
pub params: ContractParams,
pub rendered_clauses: Vec<RenderedClause>,
pub composed_at: Timestamp,
pub contract_hash: Hash256,
pub version: u32,
pub parent_contract_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenderedClause {
pub clause_id: String,
pub category: ClauseCategory,
pub title: String,
pub rendered_body: String,
pub section_number: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BreachSeverity {
Minor,
Material,
Fundamental,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BreachAssessment {
pub contract_id: String,
pub breach_severity: BreachSeverity,
pub breached_clauses: Vec<String>,
pub liability_assessment_bps: u64,
pub recommended_remedy: Remedy,
pub assessed_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Remedy {
Notice,
Cure {
cure_period_days: u32,
},
Suspension,
Termination,
Indemnification {
amount_bps: u64,
},
}
#[derive(Serialize)]
struct ContractHashPayload<'a> {
template_id: &'a str,
params: &'a ContractParams,
rendered_clauses: &'a [RenderedClause],
version: u32,
parent_contract_id: &'a Option<String>,
}
#[must_use]
pub fn default_template(bailment_type: BailmentType) -> ContractTemplate {
let (id, name, clauses) = match bailment_type {
BailmentType::Custody => (
"custody-standard-v1",
"Standard Custody Agreement",
custody_clauses(),
),
BailmentType::Processing => (
"processing-standard-v1",
"Standard Processing Agreement",
processing_clauses(),
),
BailmentType::Delegation => (
"delegation-standard-v1",
"Standard Delegation Agreement",
delegation_clauses(),
),
BailmentType::Emergency => (
"emergency-standard-v1",
"Emergency Access Agreement",
emergency_clauses(),
),
};
ContractTemplate {
id: id.to_string(),
name: name.to_string(),
bailment_type,
clauses,
version: "1.0.0".to_string(),
}
}
pub fn compose(
template: &ContractTemplate,
params: &ContractParams,
id: impl Into<String>,
composed_at: Timestamp,
) -> Result<ComposedContract, ConsentError> {
let id = id.into();
validate_constructor_metadata("contract id", &id, "composed_at", &composed_at)?;
let mut filtered_clauses = Vec::new();
for clause in &template.clauses {
match &clause.jurisdiction {
Some(j) if *j != params.jurisdiction => {
if clause.required {
return Err(ConsentError::Denied(format!(
"Required clause '{}' has jurisdiction '{}' but contract jurisdiction is '{}'",
clause.id, j, params.jurisdiction
)));
}
continue;
}
_ => filtered_clauses.push(clause),
}
}
let mut rendered_clauses = Vec::with_capacity(filtered_clauses.len());
for (i, clause) in filtered_clauses.iter().enumerate() {
let rendered_body = substitute_params(&clause.body, params);
rendered_clauses.push(RenderedClause {
clause_id: clause.id.clone(),
category: clause.category,
title: clause.title.clone(),
rendered_body,
section_number: format!("{}", i + 1),
});
}
let version = 1u32;
let template_id = template.id.clone();
let parent_contract_id = None;
let payload = ContractHashPayload {
template_id: &template_id,
params,
rendered_clauses: &rendered_clauses,
version,
parent_contract_id: &parent_contract_id,
};
let contract_hash =
hash_structured(&payload).map_err(|e| ConsentError::Denied(format!("Hash error: {e}")))?;
Ok(ComposedContract {
id,
template_id,
params: params.clone(),
rendered_clauses,
composed_at,
contract_hash,
version,
parent_contract_id,
})
}
#[must_use]
pub fn render_markdown(contract: &ComposedContract) -> String {
let mut md = String::new();
md.push_str("# Bailment Contract\n\n");
md.push_str(&format!("**Contract ID**: {}\n", contract.id));
md.push_str(&format!("**Version**: {}\n", contract.version));
md.push_str(&format!("**Composed**: {}\n", contract.composed_at));
md.push_str(&format!(
"**Effective**: {}\n",
contract.params.effective_date
));
md.push_str(&format!(
"**Expires**: {}\n",
match &contract.params.expiry_date {
Some(ts) => ts.to_string(),
None => "No expiration".to_string(),
}
));
md.push_str(&format!(
"**Jurisdiction**: {}\n",
contract.params.jurisdiction
));
md.push_str(&format!(
"**Data Classification**: {}\n\n",
contract.params.data_classification
));
md.push_str("## Parties\n\n");
md.push_str(&format!(
"- **Bailor**: {} ({})\n",
contract.params.bailor_name, contract.params.bailor_did
));
md.push_str(&format!(
"- **Bailee**: {} ({})\n\n",
contract.params.bailee_name, contract.params.bailee_did
));
for clause in &contract.rendered_clauses {
md.push_str(&format!(
"## {}. {}\n\n{}\n\n",
clause.section_number, clause.title, clause.rendered_body
));
}
md.push_str("---\n");
md.push_str(&format!("Contract Hash: {}\n", contract.contract_hash));
md
}
pub fn assess_breach(
contract: &ComposedContract,
breached_clause_ids: &[&str],
severity: BreachSeverity,
assessed_at: Timestamp,
) -> Result<BreachAssessment, ConsentError> {
validate_constructor_metadata("contract id", &contract.id, "assessed_at", &assessed_at)?;
for clause_id in breached_clause_ids {
if !contract
.rendered_clauses
.iter()
.any(|c| c.clause_id == *clause_id)
{
return Err(ConsentError::Denied(format!(
"Clause '{}' not found in contract '{}'",
clause_id, contract.id
)));
}
}
let (liability_bps, remedy) = match severity {
BreachSeverity::Minor => (0u64, Remedy::Notice),
BreachSeverity::Material => (
contract.params.liability_cap_bps / 2,
Remedy::Cure {
cure_period_days: 30,
},
),
BreachSeverity::Fundamental => (
contract.params.liability_cap_bps,
Remedy::Indemnification {
amount_bps: contract.params.liability_cap_bps,
},
),
};
Ok(BreachAssessment {
contract_id: contract.id.clone(),
breach_severity: severity,
breached_clauses: breached_clause_ids.iter().map(|s| s.to_string()).collect(),
liability_assessment_bps: liability_bps,
recommended_remedy: remedy,
assessed_at,
})
}
pub fn amend(
original: &ComposedContract,
new_params: &ContractParams,
amended_clauses: &[(String, Clause)],
id: impl Into<String>,
composed_at: Timestamp,
) -> Result<ComposedContract, ConsentError> {
let id = id.into();
validate_constructor_metadata("contract id", &id, "composed_at", &composed_at)?;
let mut clauses: Vec<RenderedClause> = original.rendered_clauses.clone();
for (target_id, new_clause) in amended_clauses {
if let Some(rc) = clauses.iter_mut().find(|c| c.clause_id == *target_id) {
rc.clause_id = new_clause.id.clone();
rc.category = new_clause.category;
rc.title = new_clause.title.clone();
rc.rendered_body = substitute_params(&new_clause.body, new_params);
} else {
let section = format!("{}", clauses.len() + 1);
clauses.push(RenderedClause {
clause_id: new_clause.id.clone(),
category: new_clause.category,
title: new_clause.title.clone(),
rendered_body: substitute_params(&new_clause.body, new_params),
section_number: section,
});
}
}
let new_version = original.version.checked_add(1).ok_or_else(|| {
ConsentError::Denied(format!(
"contract version overflow for contract '{}' at version {}",
original.id, original.version
))
})?;
let parent_contract_id = Some(original.id.clone());
let payload = ContractHashPayload {
template_id: &original.template_id,
params: new_params,
rendered_clauses: &clauses,
version: new_version,
parent_contract_id: &parent_contract_id,
};
let contract_hash =
hash_structured(&payload).map_err(|e| ConsentError::Denied(format!("Hash error: {e}")))?;
Ok(ComposedContract {
id,
template_id: original.template_id.clone(),
params: new_params.clone(),
rendered_clauses: clauses,
composed_at,
contract_hash,
version: new_version,
parent_contract_id,
})
}
#[must_use]
pub fn verify_hash(contract: &ComposedContract) -> bool {
let payload = ContractHashPayload {
template_id: &contract.template_id,
params: &contract.params,
rendered_clauses: &contract.rendered_clauses,
version: contract.version,
parent_contract_id: &contract.parent_contract_id,
};
match hash_structured(&payload) {
Ok(computed) => computed == contract.contract_hash,
Err(_) => false,
}
}
fn validate_constructor_metadata(
id_label: &str,
id: &str,
timestamp_label: &str,
timestamp: &Timestamp,
) -> Result<(), ConsentError> {
if id.trim().is_empty() {
return Err(ConsentError::Denied(format!(
"{id_label} must be caller-supplied and non-empty"
)));
}
if *timestamp == Timestamp::ZERO {
return Err(ConsentError::Denied(format!(
"{timestamp_label} must be caller-supplied and non-zero"
)));
}
Ok(())
}
fn substitute_params(body: &str, params: &ContractParams) -> String {
let mut result = body.to_string();
result = result.replace("{{bailor_name}}", ¶ms.bailor_name);
result = result.replace("{{bailee_name}}", ¶ms.bailee_name);
result = result.replace("{{bailor_did}}", params.bailor_did.as_str());
result = result.replace("{{bailee_did}}", params.bailee_did.as_str());
result = result.replace("{{effective_date}}", ¶ms.effective_date.to_string());
result = result.replace(
"{{expiry_date}}",
¶ms
.expiry_date
.map_or("No expiration".to_string(), |ts| ts.to_string()),
);
result = result.replace("{{jurisdiction}}", ¶ms.jurisdiction);
result = result.replace(
"{{data_classification}}",
¶ms.data_classification.to_string(),
);
result = result.replace(
"{{classification_custody_obligations}}",
params.data_classification.custody_obligations(),
);
result = result.replace(
"{{classification_processing_obligations}}",
params.data_classification.processing_obligations(),
);
result = result.replace(
"{{classification_breach_notice}}",
params.data_classification.breach_notice_obligations(),
);
result = result.replace(
"{{classification_liability_obligations}}",
params.data_classification.liability_obligations(),
);
result = result.replace(
"{{classification_termination_obligations}}",
params.data_classification.termination_obligations(),
);
result = result.replace(
"{{liability_cap_bps}}",
¶ms.liability_cap_bps.to_string(),
);
for (key, value) in params.custom_params.iter() {
let placeholder = format!("{{{{{key}}}}}");
result = result.replace(&placeholder, value);
}
result
}
fn custody_clauses() -> Vec<Clause> {
vec![
Clause {
id: "custody-data-custody".to_string(),
category: ClauseCategory::DataCustody,
title: "Data Custody".to_string(),
body: "{{bailee_name}} shall hold {{bailor_name}}'s data in secure custody without modification. Data classification: {{data_classification}}. {{classification_custody_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "custody-processing-rights".to_string(),
category: ClauseCategory::ProcessingRights,
title: "Processing Rights".to_string(),
body: "No processing rights are granted. {{bailee_name}} may only store and return data to {{bailor_name}}. Any handling must satisfy: {{classification_processing_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "custody-breach-remedies".to_string(),
category: ClauseCategory::BreachRemedies,
title: "Breach Remedies".to_string(),
body: "Upon breach, {{bailor_name}} shall receive notice under the classification-specific notice rule. {{classification_breach_notice}} Material breaches trigger a 30-day cure period.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "custody-liability-caps".to_string(),
category: ClauseCategory::LiabilityCaps,
title: "Liability Caps".to_string(),
body: "Total liability capped at {{liability_cap_bps}} basis points of assessed value. {{classification_liability_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "custody-dispute-resolution".to_string(),
category: ClauseCategory::DisputeResolution,
title: "Dispute Resolution".to_string(),
body: "Disputes under jurisdiction {{jurisdiction}} resolved via binding arbitration.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "custody-termination".to_string(),
category: ClauseCategory::Termination,
title: "Termination".to_string(),
body: "Either party may terminate with 30 days written notice. Data must be returned or destroyed within 15 days of termination. {{classification_termination_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "custody-jurisdiction".to_string(),
category: ClauseCategory::Jurisdiction,
title: "Governing Jurisdiction".to_string(),
body: "This agreement governed by laws of {{jurisdiction}}.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "custody-indemnification".to_string(),
category: ClauseCategory::Indemnification,
title: "Indemnification".to_string(),
body: "{{bailee_name}} shall indemnify {{bailor_name}} against third-party claims arising from {{bailee_name}}'s negligence or breach.".to_string(),
required: true,
jurisdiction: None,
},
]
}
fn processing_clauses() -> Vec<Clause> {
vec![
Clause {
id: "processing-data-custody".to_string(),
category: ClauseCategory::DataCustody,
title: "Data Custody".to_string(),
body: "{{bailee_name}} shall hold {{bailor_name}}'s data in secure custody. Data classification: {{data_classification}}. {{classification_custody_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "processing-processing-rights".to_string(),
category: ClauseCategory::ProcessingRights,
title: "Processing Rights".to_string(),
body: "{{bailee_name}} may process {{bailor_name}}'s data for purposes defined in this agreement. Processing scope limited to {{data_classification}} tier data. {{classification_processing_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "processing-breach-remedies".to_string(),
category: ClauseCategory::BreachRemedies,
title: "Breach Remedies".to_string(),
body: "Upon breach, {{bailor_name}} shall receive notice under the classification-specific notice rule. {{classification_breach_notice}} Unauthorized processing constitutes a material breach.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "processing-liability-caps".to_string(),
category: ClauseCategory::LiabilityCaps,
title: "Liability Caps".to_string(),
body: "Total liability capped at {{liability_cap_bps}} basis points of assessed value. {{classification_liability_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "processing-dispute-resolution".to_string(),
category: ClauseCategory::DisputeResolution,
title: "Dispute Resolution".to_string(),
body: "Disputes under jurisdiction {{jurisdiction}} resolved via binding arbitration.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "processing-termination".to_string(),
category: ClauseCategory::Termination,
title: "Termination".to_string(),
body: "Either party may terminate with 30 days written notice. All processing must cease immediately upon termination notice. {{classification_termination_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "processing-jurisdiction".to_string(),
category: ClauseCategory::Jurisdiction,
title: "Governing Jurisdiction".to_string(),
body: "This agreement governed by laws of {{jurisdiction}}.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "processing-indemnification".to_string(),
category: ClauseCategory::Indemnification,
title: "Indemnification".to_string(),
body: "{{bailee_name}} shall indemnify {{bailor_name}} against third-party claims arising from unauthorized processing or breach.".to_string(),
required: true,
jurisdiction: None,
},
]
}
fn delegation_clauses() -> Vec<Clause> {
vec![
Clause {
id: "delegation-data-custody".to_string(),
category: ClauseCategory::DataCustody,
title: "Data Custody".to_string(),
body: "{{bailee_name}} shall hold {{bailor_name}}'s data and may delegate custody to sub-bailees under equivalent terms. Data classification: {{data_classification}}. {{classification_custody_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "delegation-processing-rights".to_string(),
category: ClauseCategory::ProcessingRights,
title: "Processing Rights".to_string(),
body: "{{bailee_name}} may process and delegate processing of {{bailor_name}}'s data. Sub-bailees must maintain equivalent or stricter terms. {{classification_processing_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "delegation-breach-remedies".to_string(),
category: ClauseCategory::BreachRemedies,
title: "Breach Remedies".to_string(),
body: "Upon breach by {{bailee_name}} or any sub-bailee, {{bailor_name}} shall receive notice under the classification-specific notice rule. {{classification_breach_notice}} {{bailee_name}} remains liable for sub-bailee breaches.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "delegation-liability-caps".to_string(),
category: ClauseCategory::LiabilityCaps,
title: "Liability Caps".to_string(),
body: "Total liability capped at {{liability_cap_bps}} basis points. {{bailee_name}} bears full liability for sub-bailee actions. {{classification_liability_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "delegation-dispute-resolution".to_string(),
category: ClauseCategory::DisputeResolution,
title: "Dispute Resolution".to_string(),
body: "Disputes under jurisdiction {{jurisdiction}} resolved via binding arbitration. Sub-bailee disputes resolved through {{bailee_name}}.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "delegation-termination".to_string(),
category: ClauseCategory::Termination,
title: "Termination".to_string(),
body: "Either party may terminate with 30 days written notice. All sub-bailments must be terminated within 15 days of primary termination. {{classification_termination_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "delegation-jurisdiction".to_string(),
category: ClauseCategory::Jurisdiction,
title: "Governing Jurisdiction".to_string(),
body: "This agreement governed by laws of {{jurisdiction}}.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "delegation-indemnification".to_string(),
category: ClauseCategory::Indemnification,
title: "Indemnification".to_string(),
body: "{{bailee_name}} shall indemnify {{bailor_name}} against all claims arising from sub-bailee actions.".to_string(),
required: true,
jurisdiction: None,
},
]
}
fn emergency_clauses() -> Vec<Clause> {
vec![
Clause {
id: "emergency-data-custody".to_string(),
category: ClauseCategory::DataCustody,
title: "Emergency Data Custody".to_string(),
body: "{{bailee_name}} granted emergency access to {{bailor_name}}'s data. Access expires {{expiry_date}}. Justification required for all access. Data classification: {{data_classification}}. {{classification_custody_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "emergency-processing-rights".to_string(),
category: ClauseCategory::ProcessingRights,
title: "Emergency Processing Rights".to_string(),
body: "{{bailee_name}} may process data only as necessary for emergency resolution. Processing scope: {{data_classification}} tier data. All processing must be logged. {{classification_processing_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "emergency-breach-remedies".to_string(),
category: ClauseCategory::BreachRemedies,
title: "Breach Remedies".to_string(),
body: "Upon breach, {{bailor_name}} shall receive immediate notice and the classification-specific notice rule applies. {{classification_breach_notice}} Emergency access revoked instantly upon breach detection.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "emergency-liability-caps".to_string(),
category: ClauseCategory::LiabilityCaps,
title: "Liability Caps".to_string(),
body: "Total liability capped at {{liability_cap_bps}} basis points. Emergency access carries elevated liability. {{classification_liability_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "emergency-dispute-resolution".to_string(),
category: ClauseCategory::DisputeResolution,
title: "Dispute Resolution".to_string(),
body: "Disputes under jurisdiction {{jurisdiction}} resolved via expedited arbitration due to emergency nature.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "emergency-termination".to_string(),
category: ClauseCategory::Termination,
title: "Termination".to_string(),
body: "Emergency access automatically terminates at {{expiry_date}}. Either party may terminate immediately with written notice. {{classification_termination_obligations}}".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "emergency-jurisdiction".to_string(),
category: ClauseCategory::Jurisdiction,
title: "Governing Jurisdiction".to_string(),
body: "This agreement governed by laws of {{jurisdiction}}.".to_string(),
required: true,
jurisdiction: None,
},
Clause {
id: "emergency-indemnification".to_string(),
category: ClauseCategory::Indemnification,
title: "Indemnification".to_string(),
body: "{{bailee_name}} shall indemnify {{bailor_name}} against all claims arising from emergency access misuse.".to_string(),
required: true,
jurisdiction: None,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
fn alice_did() -> Did {
Did::new("did:exo:alice").unwrap()
}
fn bob_did() -> Did {
Did::new("did:exo:bob").unwrap()
}
fn test_params() -> ContractParams {
ContractParams {
bailor_name: "Alice Corp".to_string(),
bailee_name: "Bob Services".to_string(),
bailor_did: alice_did(),
bailee_did: bob_did(),
effective_date: Timestamp::new(1_700_000_000_000, 0),
expiry_date: Some(Timestamp::new(1_800_000_000_000, 0)),
jurisdiction: "US-DE".to_string(),
data_classification: DataClassification::Confidential,
liability_cap_bps: 5000, custom_params: DeterministicMap::new(),
}
}
fn test_params_with_classification(data_classification: DataClassification) -> ContractParams {
let mut params = test_params();
params.data_classification = data_classification;
params
}
fn ts(ms: u64) -> Timestamp {
Timestamp::new(ms, 0)
}
fn compose_test(template: &ContractTemplate, params: &ContractParams) -> ComposedContract {
compose(template, params, "contract-test", ts(1_700_000_000_100))
.expect("test contract composition")
}
fn compose_test_with_metadata(
template: &ContractTemplate,
params: &ContractParams,
id: &str,
composed_at: Timestamp,
) -> ComposedContract {
compose(template, params, id, composed_at).expect("test contract composition metadata")
}
fn assess_breach_test(
contract: &ComposedContract,
breached_clause_ids: &[&str],
severity: BreachSeverity,
) -> BreachAssessment {
assess_breach(
contract,
breached_clause_ids,
severity,
ts(1_700_000_000_200),
)
.expect("test breach assessment")
}
fn amend_test(
original: &ComposedContract,
new_params: &ContractParams,
amended_clauses: &[(String, Clause)],
) -> ComposedContract {
amend(
original,
new_params,
amended_clauses,
"contract-amendment-test",
ts(1_700_000_000_300),
)
.expect("test amendment")
}
fn compose_custody() -> ComposedContract {
let template = default_template(BailmentType::Custody);
compose_test(&template, &test_params())
}
fn rendered_contract_body(contract: &ComposedContract) -> String {
contract
.rendered_clauses
.iter()
.map(|clause| clause.rendered_body.clone())
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn contract_constructors_have_no_internal_entropy_or_wall_clock() {
let source = include_str!("contract.rs");
let uuid_pattern = format!("{}{}", "Uuid::", "new_v4()");
let now_pattern = format!("{}{}", "Timestamp::", "now_utc()");
assert!(
!source.contains(&uuid_pattern),
"contract constructors must receive caller-supplied IDs"
);
assert!(
!source.contains(&now_pattern),
"contract constructors must receive caller-supplied HLC timestamps"
);
}
#[test]
fn compose_uses_caller_supplied_metadata() {
let template = default_template(BailmentType::Custody);
let contract =
compose_test_with_metadata(&template, &test_params(), "contract-explicit", ts(4321));
assert_eq!(contract.id, "contract-explicit");
assert_eq!(contract.composed_at, ts(4321));
assert_eq!(contract.version, 1);
assert_eq!(contract.parent_contract_id, None);
}
#[test]
fn compose_rejects_empty_id() {
let template = default_template(BailmentType::Custody);
let err = compose(&template, &test_params(), " ", ts(1000)).unwrap_err();
assert_eq!(
err,
ConsentError::Denied("contract id must be caller-supplied and non-empty".into())
);
}
#[test]
fn compose_rejects_zero_composed_at() {
let template = default_template(BailmentType::Custody);
let err = compose(
&template,
&test_params(),
"contract-explicit",
Timestamp::ZERO,
)
.unwrap_err();
assert_eq!(
err,
ConsentError::Denied("composed_at must be caller-supplied and non-zero".into())
);
}
#[test]
fn assess_breach_uses_caller_supplied_timestamp() {
let contract = compose_custody();
let clause_id = contract.rendered_clauses[0].clause_id.as_str();
let assessment = assess_breach(&contract, &[clause_id], BreachSeverity::Minor, ts(4567))
.expect("breach assessment");
assert_eq!(assessment.assessed_at, ts(4567));
}
#[test]
fn assess_breach_rejects_zero_timestamp() {
let contract = compose_custody();
let clause_id = contract.rendered_clauses[0].clause_id.as_str();
let err = assess_breach(
&contract,
&[clause_id],
BreachSeverity::Minor,
Timestamp::ZERO,
)
.unwrap_err();
assert_eq!(
err,
ConsentError::Denied("assessed_at must be caller-supplied and non-zero".into())
);
}
#[test]
fn amend_uses_caller_supplied_metadata() {
let original = compose_custody();
let amended = amend(
&original,
&test_params(),
&[],
"contract-amendment-explicit",
ts(5678),
)
.expect("amendment");
assert_eq!(amended.id, "contract-amendment-explicit");
assert_eq!(amended.composed_at, ts(5678));
assert_eq!(amended.parent_contract_id, Some(original.id.clone()));
}
#[test]
fn amend_rejects_placeholder_metadata() {
let original = compose_custody();
let empty_id = amend(&original, &test_params(), &[], " ", ts(5678)).unwrap_err();
assert_eq!(
empty_id,
ConsentError::Denied("contract id must be caller-supplied and non-empty".into())
);
let zero_time = amend(
&original,
&test_params(),
&[],
"contract-amendment-explicit",
Timestamp::ZERO,
)
.unwrap_err();
assert_eq!(
zero_time,
ConsentError::Denied("composed_at must be caller-supplied and non-zero".into())
);
}
fn all_categories() -> Vec<ClauseCategory> {
vec![
ClauseCategory::DataCustody,
ClauseCategory::ProcessingRights,
ClauseCategory::BreachRemedies,
ClauseCategory::LiabilityCaps,
ClauseCategory::DisputeResolution,
ClauseCategory::Termination,
ClauseCategory::Jurisdiction,
ClauseCategory::Indemnification,
]
}
#[test]
fn test_default_template_custody() {
let template = default_template(BailmentType::Custody);
assert_eq!(template.bailment_type, BailmentType::Custody);
assert_eq!(template.clauses.len(), 8);
let categories: Vec<ClauseCategory> = template.clauses.iter().map(|c| c.category).collect();
for cat in all_categories() {
assert!(
categories.contains(&cat),
"Custody template missing category: {cat:?}"
);
}
assert!(template.clauses.iter().all(|c| c.required));
}
#[test]
fn test_default_template_processing() {
let template = default_template(BailmentType::Processing);
assert_eq!(template.bailment_type, BailmentType::Processing);
assert_eq!(template.clauses.len(), 8);
let categories: Vec<ClauseCategory> = template.clauses.iter().map(|c| c.category).collect();
for cat in all_categories() {
assert!(
categories.contains(&cat),
"Processing template missing category: {cat:?}"
);
}
assert!(template.clauses.iter().all(|c| c.required));
}
#[test]
fn test_compose_substitutes_params() {
let contract = compose_custody();
let all_bodies: String = contract
.rendered_clauses
.iter()
.map(|c| c.rendered_body.clone())
.collect::<Vec<_>>()
.join(" ");
assert!(
all_bodies.contains("Alice Corp"),
"Bailor name not substituted"
);
assert!(
all_bodies.contains("Bob Services"),
"Bailee name not substituted"
);
assert!(all_bodies.contains("US-DE"), "Jurisdiction not substituted");
assert!(
all_bodies.contains("Confidential"),
"Data classification not substituted"
);
assert!(all_bodies.contains("5000"), "Liability cap not substituted");
assert!(
!all_bodies.contains("{{"),
"Unsubstituted placeholders remain"
);
}
#[test]
fn data_classification_renders_tier_specific_obligations() {
let template = default_template(BailmentType::Processing);
let cases = [
(
DataClassification::Public,
"Public data may be stored with baseline integrity controls",
"Public data processing is limited to authorized use",
"Public classification breaches require notice within 10 business days",
"Public data liability remains limited to integrity, availability, and attribution failures",
"Public data must be returned, deleted, or left published according to the bailor's written instruction",
),
(
DataClassification::Internal,
"Internal data requires organization-scoped access controls",
"Internal data processing is limited to personnel, services, and agents operating under the bailee's internal authorization boundary",
"Internal classification breaches require notice within 5 business days",
"Internal data liability includes unauthorized internal disclosure and unauthorized retention",
"Internal data must be returned or deleted with an internal access revocation record",
),
(
DataClassification::Confidential,
"Confidential data requires least-privilege access, encrypted storage, encrypted transfer, and access logging",
"Confidential data processing requires purpose-bound authorization and prohibits secondary use without signed amendment",
"Confidential classification breaches require notice within 72 hours",
"Confidential data liability includes unauthorized disclosure, unauthorized processing, and control failure",
"Confidential data must be returned or destroyed with verifiable destruction evidence",
),
(
DataClassification::Restricted,
"Restricted data requires documented need-to-know approval, segregated storage, encrypted transfer, and dual-control access for export",
"Restricted data processing is limited to named workflows and named operators",
"Restricted classification breaches require notice within 24 hours",
"Restricted data liability includes unauthorized access, export, delegation, or segregation failure",
"Restricted data must be quarantined immediately on termination until return or destruction is receipt-backed",
),
(
DataClassification::Regulated,
"Regulated data requires statutory control mapping, jurisdiction-specific handling, audit-ready access logs, and retention policy enforcement",
"Regulated data processing is limited to enumerated legal bases and auditable processing records",
"Regulated classification breaches require notice within the shortest applicable legal window, not exceeding 24 hours",
"Regulated data liability includes regulatory reporting failure, unlawful processing, and retention violation",
"Regulated data must follow the governing retention schedule and produce a compliance evidence package on termination",
),
];
let mut rendered_bodies = Vec::new();
for (
classification,
custody_obligation,
processing_obligation,
breach_obligation,
liability_obligation,
termination_obligation,
) in cases
{
let contract =
compose_test(&template, &test_params_with_classification(classification));
let body = rendered_contract_body(&contract);
assert!(
body.contains(custody_obligation),
"{classification:?} custody obligation missing"
);
assert!(
body.contains(processing_obligation),
"{classification:?} processing obligation missing"
);
assert!(
body.contains(breach_obligation),
"{classification:?} breach obligation missing"
);
assert!(
body.contains(liability_obligation),
"{classification:?} liability obligation missing"
);
assert!(
body.contains(termination_obligation),
"{classification:?} termination obligation missing"
);
rendered_bodies.push((classification, body));
}
for (index, (left_classification, left_body)) in rendered_bodies.iter().enumerate() {
for (right_classification, right_body) in rendered_bodies.iter().skip(index + 1) {
assert_ne!(
left_body, right_body,
"{left_classification:?} and {right_classification:?} rendered identical contract bodies"
);
}
}
}
#[test]
fn standard_templates_bind_classification_obligation_placeholders() {
for bailment_type in [
BailmentType::Custody,
BailmentType::Processing,
BailmentType::Delegation,
BailmentType::Emergency,
] {
let template = default_template(bailment_type);
let template_body = template
.clauses
.iter()
.map(|clause| clause.body.clone())
.collect::<Vec<_>>()
.join(" ");
for placeholder in [
"{{classification_custody_obligations}}",
"{{classification_processing_obligations}}",
"{{classification_breach_notice}}",
"{{classification_liability_obligations}}",
"{{classification_termination_obligations}}",
] {
assert!(
template_body.contains(placeholder),
"{bailment_type:?} standard clauses must bind {placeholder}"
);
}
}
}
#[test]
fn test_compose_produces_deterministic_hash() {
let template = default_template(BailmentType::Custody);
let params = test_params();
let c1 = compose_test(&template, ¶ms);
let c2 = compose_test(&template, ¶ms);
assert_eq!(c1.id, c2.id);
assert_eq!(c1.composed_at, c2.composed_at);
assert_eq!(c1.contract_hash, c2.contract_hash);
}
#[test]
fn test_compose_hash_changes_with_params() {
let template = default_template(BailmentType::Custody);
let params1 = test_params();
let mut params2 = test_params();
params2.liability_cap_bps = 9999;
let c1 = compose_test(&template, ¶ms1);
let c2 = compose_test(&template, ¶ms2);
assert_ne!(c1.contract_hash, c2.contract_hash);
}
#[test]
fn test_render_markdown_has_all_sections() {
let contract = compose_custody();
let md = render_markdown(&contract);
for clause in &contract.rendered_clauses {
assert!(
md.contains(&clause.title),
"Markdown missing clause title: {}",
clause.title
);
assert!(
md.contains(&format!("{}.", clause.section_number)),
"Markdown missing section number: {}",
clause.section_number
);
}
assert!(md.contains("# Bailment Contract"));
assert!(md.contains("## Parties"));
assert!(md.contains("Contract Hash:"));
}
#[test]
fn test_render_markdown_party_names() {
let contract = compose_custody();
let md = render_markdown(&contract);
assert!(
md.contains("Alice Corp"),
"Bailor name missing from markdown"
);
assert!(
md.contains("Bob Services"),
"Bailee name missing from markdown"
);
assert!(
md.contains("did:exo:alice"),
"Bailor DID missing from markdown"
);
assert!(
md.contains("did:exo:bob"),
"Bailee DID missing from markdown"
);
}
#[test]
fn test_breach_assessment_minor() {
let contract = compose_custody();
let clause_id = contract.rendered_clauses[0].clause_id.as_str();
let assessment = assess_breach_test(&contract, &[clause_id], BreachSeverity::Minor);
assert_eq!(assessment.breach_severity, BreachSeverity::Minor);
assert_eq!(assessment.recommended_remedy, Remedy::Notice);
assert_eq!(assessment.liability_assessment_bps, 0);
}
#[test]
fn test_breach_assessment_material() {
let contract = compose_custody();
let clause_id = contract.rendered_clauses[0].clause_id.as_str();
let assessment = assess_breach_test(&contract, &[clause_id], BreachSeverity::Material);
assert_eq!(assessment.breach_severity, BreachSeverity::Material);
assert_eq!(
assessment.recommended_remedy,
Remedy::Cure {
cure_period_days: 30
}
);
assert_eq!(assessment.liability_assessment_bps, 2500); }
#[test]
fn test_breach_assessment_fundamental() {
let contract = compose_custody();
let clause_id = contract.rendered_clauses[0].clause_id.as_str();
let assessment = assess_breach_test(&contract, &[clause_id], BreachSeverity::Fundamental);
assert_eq!(assessment.breach_severity, BreachSeverity::Fundamental);
assert_eq!(
assessment.recommended_remedy,
Remedy::Indemnification { amount_bps: 5000 }
);
assert_eq!(assessment.liability_assessment_bps, 5000);
}
#[test]
fn test_breach_invalid_clause_id() {
let contract = compose_custody();
let result = assess_breach(
&contract,
&["nonexistent-clause"],
BreachSeverity::Minor,
ts(1_700_000_000_200),
);
assert!(result.is_err());
match result {
Err(ConsentError::Denied(msg)) => {
assert!(msg.contains("nonexistent-clause"));
}
other => panic!("Expected Denied error, got: {other:?}"),
}
}
#[test]
fn test_amend_creates_new_version() {
let original = compose_custody();
let new_params = test_params();
let amended = amend_test(&original, &new_params, &[]);
assert_eq!(amended.version, original.version + 1);
assert_eq!(amended.parent_contract_id, Some(original.id.clone()));
assert_ne!(amended.id, original.id);
}
#[test]
fn test_amend_rejects_version_overflow() {
let mut original = compose_custody();
original.version = u32::MAX;
let err = amend(
&original,
&test_params(),
&[],
"overflow-amendment",
ts(1_700_000_000_350),
)
.unwrap_err();
match err {
ConsentError::Denied(reason) => {
assert!(reason.contains("contract version overflow"));
assert!(reason.contains(&original.id));
}
other => panic!("expected denial for version overflow, got {other:?}"),
}
}
#[test]
fn test_amend_preserves_parent_hash() {
let original = compose_custody();
let original_hash = original.contract_hash;
let mut new_params = test_params();
new_params.liability_cap_bps = 9000;
let _amended = amend_test(&original, &new_params, &[]);
assert_eq!(original.contract_hash, original_hash);
}
#[test]
fn test_verify_hash_valid() {
let contract = compose_custody();
assert!(verify_hash(&contract));
}
#[test]
fn verify_hash_rejects_tampered_amendment_parent_contract_id() {
let original = compose_custody();
let mut amended = amend_test(&original, &test_params(), &[]);
assert!(verify_hash(&amended));
amended.parent_contract_id = Some("contract-forged-parent".to_string());
assert!(!verify_hash(&amended));
let mut orphaned = amend_test(&original, &test_params(), &[]);
orphaned.parent_contract_id = None;
assert!(!verify_hash(&orphaned));
}
#[test]
fn test_verify_hash_tampered() {
let mut contract = compose_custody();
contract.rendered_clauses[0].rendered_body = "TAMPERED CONTENT".to_string();
assert!(!verify_hash(&contract));
}
#[test]
fn test_no_floating_point() {
let contract = compose_custody();
let _cap: u64 = contract.params.liability_cap_bps;
assert_eq!(contract.params.liability_cap_bps, 5000u64);
let clause_id = contract.rendered_clauses[0].clause_id.as_str();
let assessment = assess_breach_test(&contract, &[clause_id], BreachSeverity::Material);
let _liability: u64 = assessment.liability_assessment_bps;
assert_eq!(assessment.liability_assessment_bps, 2500u64);
assert_eq!(5000u64 / 2, 2500u64);
}
#[test]
fn test_default_template_delegation() {
let template = default_template(BailmentType::Delegation);
assert_eq!(template.bailment_type, BailmentType::Delegation);
assert_eq!(template.id, "delegation-standard-v1");
assert_eq!(template.name, "Standard Delegation Agreement");
assert!(!template.clauses.is_empty());
assert!(template.clauses.iter().all(|c| c.required));
let cats: Vec<ClauseCategory> = template.clauses.iter().map(|c| c.category).collect();
assert!(cats.contains(&ClauseCategory::ProcessingRights));
}
#[test]
fn test_default_template_emergency() {
let template = default_template(BailmentType::Emergency);
assert_eq!(template.bailment_type, BailmentType::Emergency);
assert_eq!(template.id, "emergency-standard-v1");
assert_eq!(template.name, "Emergency Access Agreement");
assert!(!template.clauses.is_empty());
assert!(template.clauses.iter().all(|c| c.required));
let cats: Vec<ClauseCategory> = template.clauses.iter().map(|c| c.category).collect();
assert!(cats.contains(&ClauseCategory::Termination));
}
#[test]
fn test_compose_delegation_template_succeeds() {
let template = default_template(BailmentType::Delegation);
let contract = compose_test(&template, &test_params());
assert!(!contract.rendered_clauses.is_empty());
assert_ne!(contract.contract_hash, Hash256::ZERO);
for (i, rc) in contract.rendered_clauses.iter().enumerate() {
assert_eq!(rc.section_number, format!("{}", i + 1));
}
}
#[test]
fn test_compose_emergency_template_succeeds() {
let template = default_template(BailmentType::Emergency);
let contract = compose_test(&template, &test_params());
assert!(!contract.rendered_clauses.is_empty());
assert_ne!(contract.contract_hash, Hash256::ZERO);
}
#[test]
fn test_compose_skips_optional_foreign_jurisdiction_clause() {
let mut template = default_template(BailmentType::Custody);
template.clauses.push(Clause {
id: "optional-eu-only".to_string(),
category: ClauseCategory::Jurisdiction,
title: "EU-Only Optional".to_string(),
body: "GDPR-specific clause body.".to_string(),
required: false,
jurisdiction: Some("EU-DE".to_string()),
});
let contract = compose_test(&template, &test_params());
assert!(
contract
.rendered_clauses
.iter()
.all(|c| c.clause_id != "optional-eu-only"),
"Optional foreign-jurisdiction clause must be filtered out"
);
}
#[test]
fn test_compose_errors_on_required_foreign_jurisdiction_clause() {
let mut template = default_template(BailmentType::Custody);
template.clauses.push(Clause {
id: "required-eu-only".to_string(),
category: ClauseCategory::Jurisdiction,
title: "EU-Only Required".to_string(),
body: "GDPR-specific clause body.".to_string(),
required: true,
jurisdiction: Some("EU-DE".to_string()),
});
let result = compose(
&template,
&test_params(),
"contract-test",
ts(1_700_000_000_100),
);
match result {
Err(ConsentError::Denied(msg)) => {
assert!(msg.contains("required-eu-only"));
assert!(msg.contains("EU-DE"));
assert!(msg.contains("US-DE"));
}
other => panic!("Expected Denied error, got: {other:?}"),
}
}
#[test]
fn test_compose_keeps_matching_jurisdiction_clause() {
let mut template = default_template(BailmentType::Custody);
template.clauses.push(Clause {
id: "matching-us-clause".to_string(),
category: ClauseCategory::Jurisdiction,
title: "US-DE Specific".to_string(),
body: "Delaware-specific clause body.".to_string(),
required: false,
jurisdiction: Some("US-DE".to_string()),
});
let contract = compose_test(&template, &test_params());
assert!(
contract
.rendered_clauses
.iter()
.any(|c| c.clause_id == "matching-us-clause"),
"Matching-jurisdiction clause must be included"
);
}
#[test]
fn test_amend_replaces_existing_clause() {
let original = compose_custody();
let target_id = original.rendered_clauses[0].clause_id.clone();
let replacement = Clause {
id: "custody-v2-revised".to_string(),
category: ClauseCategory::DataCustody,
title: "Revised Custody".to_string(),
body:
"Revised custody terms: Data must be returned to {{bailor_name}} upon termination."
.to_string(),
required: true,
jurisdiction: None,
};
let amended = amend(
&original,
&test_params(),
&[(target_id.clone(), replacement.clone())],
"contract-amendment-test",
ts(1_700_000_000_300),
)
.unwrap();
assert_eq!(
amended.rendered_clauses.len(),
original.rendered_clauses.len()
);
assert!(
amended
.rendered_clauses
.iter()
.any(|c| c.clause_id == "custody-v2-revised"),
"Replacement clause must appear in amended contract"
);
assert!(
amended
.rendered_clauses
.iter()
.all(|c| c.clause_id != target_id),
"Original clause id must be replaced"
);
let revised_body = amended
.rendered_clauses
.iter()
.find(|c| c.clause_id == "custody-v2-revised")
.map(|c| c.rendered_body.clone())
.unwrap();
assert!(
revised_body.contains("Alice Corp"),
"Replacement must have params substituted"
);
assert!(!revised_body.contains("{{"));
}
#[test]
fn test_amend_appends_new_clause_when_not_present() {
let original = compose_custody();
let original_len = original.rendered_clauses.len();
let new_clause = Clause {
id: "new-amendment-clause".to_string(),
category: ClauseCategory::Indemnification,
title: "New Indemnification Rider".to_string(),
body: "Added by amendment for {{bailee_name}}.".to_string(),
required: true,
jurisdiction: None,
};
let amended = amend(
&original,
&test_params(),
&[("NON-EXISTENT-CLAUSE-ID".to_string(), new_clause)],
"contract-amendment-test",
ts(1_700_000_000_300),
)
.unwrap();
assert_eq!(
amended.rendered_clauses.len(),
original_len + 1,
"Unknown target_id must APPEND rather than replace"
);
let appended = amended
.rendered_clauses
.last()
.expect("amended contract has at least one clause");
assert_eq!(appended.clause_id, "new-amendment-clause");
assert_eq!(
appended.section_number,
format!("{}", original_len + 1),
"Appended clause must take the next section number"
);
assert!(appended.rendered_body.contains("Bob Services"));
}
#[test]
fn test_amend_replace_and_append_mixed() {
let original = compose_custody();
let target_id = original.rendered_clauses[1].clause_id.clone();
let replacement = Clause {
id: "replacement-mixed".to_string(),
category: original.rendered_clauses[1].category,
title: "Replacement".to_string(),
body: "Replaced text for {{jurisdiction}}.".to_string(),
required: true,
jurisdiction: None,
};
let addition = Clause {
id: "addition-mixed".to_string(),
category: ClauseCategory::DisputeResolution,
title: "Additional".to_string(),
body: "Added text.".to_string(),
required: true,
jurisdiction: None,
};
let amended = amend(
&original,
&test_params(),
&[
(target_id, replacement),
("UNKNOWN-ID".to_string(), addition),
],
"contract-amendment-test",
ts(1_700_000_000_300),
)
.unwrap();
assert_eq!(
amended.rendered_clauses.len(),
original.rendered_clauses.len() + 1
);
assert!(
amended
.rendered_clauses
.iter()
.any(|c| c.clause_id == "replacement-mixed")
);
assert!(
amended
.rendered_clauses
.iter()
.any(|c| c.clause_id == "addition-mixed")
);
}
#[test]
fn test_amend_changes_contract_hash() {
let original = compose_custody();
let amended = amend_test(&original, &test_params(), &[]);
assert_ne!(amended.contract_hash, original.contract_hash);
}
#[test]
fn test_render_markdown_no_expiration() {
let template = default_template(BailmentType::Custody);
let mut params = test_params();
params.expiry_date = None;
let contract = compose_test(&template, ¶ms);
let md = render_markdown(&contract);
assert!(
md.contains("**Expires**: No expiration"),
"Expected 'No expiration' line in Markdown; got:\n{md}"
);
}
#[test]
fn test_render_markdown_includes_expiry_when_set() {
let contract = compose_custody();
let md = render_markdown(&contract);
assert!(
md.contains("1800000000000")
|| md.contains("1_800_000_000_000")
|| md.contains("2027")
|| md.contains("Expires"),
"Expected a non-'No expiration' Expires line; got:\n{md}"
);
assert!(
!md.contains("**Expires**: No expiration"),
"Should not render 'No expiration' when expiry is set"
);
}
#[test]
fn test_verify_hash_rejects_tampered_params() {
let mut contract = compose_custody();
contract.params.bailor_name = "Malicious Party".to_string();
assert!(!verify_hash(&contract));
}
#[test]
fn test_verify_hash_rejects_tampered_version() {
let mut contract = compose_custody();
contract.version = contract.version.saturating_add(1);
assert!(!verify_hash(&contract));
}
#[test]
fn test_compose_includes_all_jurisdiction_neutral_required_clauses() {
let template = default_template(BailmentType::Custody);
let contract = compose_test(&template, &test_params());
assert_eq!(contract.rendered_clauses.len(), template.clauses.len());
}
#[test]
fn test_breach_multiple_clauses() {
let contract = compose_custody();
let id0 = contract.rendered_clauses[0].clause_id.clone();
let id1 = contract.rendered_clauses[1].clause_id.clone();
let a = assess_breach_test(&contract, &[&id0, &id1], BreachSeverity::Material);
assert_eq!(a.breached_clauses.len(), 2);
assert!(a.breached_clauses.contains(&id0));
assert!(a.breached_clauses.contains(&id1));
}
}