use serde::{Deserialize, Serialize};
use super::invitation::{canonical_json_digest, parse_rfc3339_to_unix};
use super::SubjectRef;
use crate::attestation::{Signer, SignerError};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use sha2::{Digest, Sha256};
use ed25519_dalek::{Signature, VerifyingKey};
pub const TYPE_ACTION_V2: &str = "treeship/action/v2";
pub fn payload_type_v2(suffix: &str) -> String {
format!("application/vnd.treeship.{}.v2+json", suffix)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Revocation {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mandate {
pub grant_id: String,
pub grantor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer_sig: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub objective_hash: Option<String>,
#[serde(default)]
pub scope: Vec<String>,
pub audience: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_request_id: Option<String>,
#[serde(default)]
pub delegation_depth: u32,
pub issued_at: String,
pub expiry: String,
#[serde(default)]
pub max_delegation: u32,
pub revocation: Revocation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chain: Vec<Grant>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cost {
pub unit: String,
pub amount: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Witness {
pub observer: String,
pub observation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
impl Witness {
pub fn is_signed(&self) -> bool {
self.signature.is_some()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Effect {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub readback: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes_moved: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<Cost>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub side_effects: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_snapshot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect_confidence: Option<EffectConfidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub witnesses: Vec<Witness>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finality: Option<EffectFinality>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolution: Option<Resolution>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EffectConfidence {
Verified,
Partial,
Ambiguous,
Unknown,
NotVerified,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EffectFinality {
NotAttempted,
Initiated,
Finalized,
Failed,
Indeterminate,
}
impl EffectFinality {
pub fn is_resolved(self) -> bool {
matches!(
self,
Self::NotAttempted | Self::Finalized | Self::Failed
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Resolution {
pub deadline: String,
pub on_deadline: DeadlineEvent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeadlineEvent {
Timeout,
Escalate,
Tombstone,
Inherit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolutionStatus {
Resolved,
Indefinite,
Pending { seconds_remaining: i64 },
Breached {
on_deadline: DeadlineEvent,
seconds_overdue: i64,
},
BadDeadline,
}
pub fn check_resolution(effect: &Effect, now_unix: i64) -> ResolutionStatus {
let resolved = effect
.finality
.map(EffectFinality::is_resolved)
.unwrap_or(false);
if resolved {
return ResolutionStatus::Resolved;
}
let res = match &effect.resolution {
Some(r) => r,
None => return ResolutionStatus::Indefinite,
};
let deadline = match parse_rfc3339_to_unix(&res.deadline) {
Some(t) if t <= i64::MAX as u64 => t as i64,
_ => return ResolutionStatus::BadDeadline,
};
if now_unix > deadline {
ResolutionStatus::Breached {
on_deadline: res.on_deadline,
seconds_overdue: now_unix - deadline,
}
} else {
ResolutionStatus::Pending {
seconds_remaining: deadline - now_unix,
}
}
}
impl Effect {
pub fn has_independent_evidence(&self) -> bool {
self.readback.is_some()
}
pub fn signed_witnesses(&self) -> impl Iterator<Item = &Witness> {
self.witnesses.iter().filter(|w| w.is_signed())
}
pub fn evidence_ceiling(&self) -> EffectConfidence {
if self.has_independent_evidence() {
EffectConfidence::Verified
} else {
EffectConfidence::NotVerified
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeIdentity {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_schema_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt_hash: Option<String>,
}
impl RuntimeIdentity {
pub fn is_unbound(&self) -> bool {
self.provider.is_none()
&& self.model.is_none()
&& self.tool_schema_hash.is_none()
&& self.system_prompt_hash.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionStatementV2 {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub actor: String,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
#[serde(default, skip_serializing_if = "subject_is_empty")]
pub subject: SubjectRef,
#[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub mandate: Mandate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<Effect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<RuntimeIdentity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
fn subject_is_empty(s: &SubjectRef) -> bool {
s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
}
impl ActionStatementV2 {
pub fn new(actor: impl Into<String>, action: impl Into<String>, mandate: Mandate) -> Self {
Self {
type_: TYPE_ACTION_V2.into(),
timestamp: super::unix_to_rfc3339(now_unix()),
actor: actor.into(),
action: action.into(),
audience: None,
subject: SubjectRef::default(),
parent_id: None,
mandate,
effect: None,
runtime: None,
meta: None,
}
}
}
fn now_unix() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn action_in_scope(action: &str, scope: &[String]) -> bool {
scope.iter().any(|entry| scope_entry_matches(entry, action))
}
fn scope_entry_matches(entry: &str, action: &str) -> bool {
if let Some(prefix) = entry.strip_suffix(".*") {
action == prefix || action.starts_with(&format!("{prefix}."))
} else {
entry == action
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RevocationStatus {
NotRevoked,
RevokedAt(String),
Unknown(String),
}
pub trait RevocationSource {
fn status(&self, grant_id: &str, path: &str) -> RevocationStatus;
}
pub struct NoRevocationSource;
impl RevocationSource for NoRevocationSource {
fn status(&self, _grant_id: &str, path: &str) -> RevocationStatus {
RevocationStatus::Unknown(format!("no revocation source configured for path '{path}'"))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MandateVerdict {
Pass,
Unverified(Vec<String>),
Fail(Vec<String>),
}
impl MandateVerdict {
pub fn is_pass(&self) -> bool {
matches!(self, MandateVerdict::Pass)
}
}
pub fn verify_mandate(
stmt: &ActionStatementV2,
revocation: &dyn RevocationSource,
) -> MandateVerdict {
let mut fail: Vec<String> = Vec::new();
let mut unver: Vec<String> = Vec::new();
if stmt.type_ != TYPE_ACTION_V2 {
return MandateVerdict::Fail(vec![format!(
"statement type '{}' is not {TYPE_ACTION_V2}",
stmt.type_
)]);
}
let m = &stmt.mandate;
let signed_at = match parse_rfc3339_to_unix(&stmt.timestamp) {
Some(t) => t,
None => {
return MandateVerdict::Fail(vec![format!(
"timestamp '{}' is not RFC 3339",
stmt.timestamp
)])
}
};
if m.scope.is_empty() {
fail.push("mandate.scope is empty: it authorizes no action".into());
} else if !action_in_scope(&stmt.action, &m.scope) {
fail.push(format!(
"action '{}' is not in mandate scope {:?}",
stmt.action, m.scope
));
}
if m.audience.trim().is_empty() {
fail.push("mandate.audience is empty: the grant is not bound to an audience".into());
} else {
match &stmt.audience {
Some(a) if a == &m.audience => {}
Some(a) => fail.push(format!(
"action audience '{a}' does not match mandate audience '{}'",
m.audience
)),
None => unver
.push("action recorded no audience; cannot confirm it matched the mandate".into()),
}
}
match (
parse_rfc3339_to_unix(&m.issued_at),
parse_rfc3339_to_unix(&m.expiry),
) {
(Some(issued), Some(expiry)) => {
if expiry <= issued {
fail.push(format!(
"mandate expiry '{}' is not after issued_at '{}'",
m.expiry, m.issued_at
));
}
if signed_at < issued {
fail.push(format!(
"signed_at '{}' is before mandate issued_at '{}'",
stmt.timestamp, m.issued_at
));
}
if signed_at >= expiry {
fail.push(format!(
"signed_at '{}' is at or after mandate expiry '{}'",
stmt.timestamp, m.expiry
));
}
}
_ => fail.push(format!(
"mandate issued_at '{}' / expiry '{}' are not both RFC 3339",
m.issued_at, m.expiry
)),
}
match revocation.status(&m.grant_id, &m.revocation.path) {
RevocationStatus::NotRevoked => {}
RevocationStatus::RevokedAt(ts) => match parse_rfc3339_to_unix(&ts) {
Some(revoked_at) => {
if signed_at >= revoked_at {
fail.push(format!(
"grant was revoked at '{ts}'; signed_at '{}' is not before revocation",
stmt.timestamp
));
}
}
None => unver.push(format!("revocation timestamp '{ts}' is not RFC 3339")),
},
RevocationStatus::Unknown(reason) => {
unver.push(format!("revocation could not be checked: {reason}"))
}
}
if !fail.is_empty() {
MandateVerdict::Fail(fail)
} else if !unver.is_empty() {
MandateVerdict::Unverified(unver)
} else {
MandateVerdict::Pass
}
}
pub trait WitnessAuthority {
fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
}
pub struct NoWitnessAuthority;
impl WitnessAuthority for NoWitnessAuthority {
fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
false
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectVerdict {
pub effective_confidence: EffectConfidence,
pub claimed_confidence: Option<EffectConfidence>,
pub trusted_witnesses: usize,
pub notes: Vec<String>,
pub effective_finality: Option<EffectFinality>,
pub claimed_finality: Option<EffectFinality>,
}
impl EffectVerdict {
pub fn is_verified(&self) -> bool {
self.effective_confidence == EffectConfidence::Verified
}
}
pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
let effect = match &stmt.effect {
Some(e) => e,
None => {
return EffectVerdict {
effective_confidence: EffectConfidence::NotVerified,
claimed_confidence: None,
trusted_witnesses: 0,
notes: vec!["receipt carries no effect block; effect is unverified".into()],
effective_finality: None,
claimed_finality: None,
}
}
};
let mut notes: Vec<String> = Vec::new();
let trusted_witnesses = effect
.witnesses
.iter()
.filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
.count();
let untrusted = effect.witnesses.len() - trusted_witnesses;
if untrusted > 0 {
notes.push(format!(
"{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
effect.witnesses.len()
));
}
let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
let claimed = effect.effect_confidence;
let effective = match claimed {
None => {
notes.push("actor recorded no effect_confidence; effect is unverified".into());
EffectConfidence::NotVerified
}
Some(EffectConfidence::Verified) if !has_evidence => {
notes.push(
"actor claimed Verified but bundled no independent evidence \
(no readback, no trusted witness); downgraded to NotVerified"
.into(),
);
EffectConfidence::NotVerified
}
Some(c) => c,
};
let claimed_finality = effect.finality;
let effective_finality = match claimed_finality {
Some(EffectFinality::Finalized) if !has_evidence => {
notes.push(
"actor claimed the effect Finalized but bundled no independent evidence \
(no readback, no trusted witness); downgraded to Indeterminate"
.into(),
);
Some(EffectFinality::Indeterminate)
}
other => other,
};
EffectVerdict {
effective_confidence: effective,
claimed_confidence: claimed,
trusted_witnesses,
notes,
effective_finality,
claimed_finality,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grant {
pub grant_id: String,
pub grantor: String,
#[serde(default)]
pub scope: Vec<String>,
pub audience: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_request_id: Option<String>,
#[serde(default)]
pub delegation_depth: u32,
pub issued_at: String,
pub expiry: String,
#[serde(default)]
pub max_delegation: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub objective_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer_sig: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_grant_id: Option<String>,
}
impl Grant {
pub fn canonical_for_signing(&self) -> String {
let scope_digest = canonical_json_digest(&self.scope);
format!(
"v2|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
self.grantor,
scope_digest,
self.audience,
self.parent_request_id.as_deref().unwrap_or(""),
self.parent_grant_id.as_deref().unwrap_or(""),
self.delegation_depth,
self.issued_at,
self.expiry,
self.max_delegation,
self.objective_hash.as_deref().unwrap_or(""),
)
}
pub fn derive_grant_id(&self) -> String {
let digest = Sha256::digest(self.canonical_for_signing().as_bytes());
format!("grn_{}", hex::encode(&digest[..8]))
}
pub fn id_is_consistent(&self) -> bool {
self.grant_id == self.derive_grant_id()
}
pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
Ok(URL_SAFE_NO_PAD.encode(sig))
}
pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
if !self.id_is_consistent() {
return false;
}
let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
Ok(b) if b.len() == 32 => b,
_ => return false,
};
let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
Ok(b) if b.len() == 64 => b,
_ => return false,
};
let mut pk = [0u8; 32];
pk.copy_from_slice(&pk_bytes);
let mut sig = [0u8; 64];
sig.copy_from_slice(&sig_bytes);
let vk = match VerifyingKey::from_bytes(&pk) {
Ok(k) => k,
Err(_) => return false,
};
vk.verify_strict(
self.canonical_for_signing().as_bytes(),
&Signature::from_bytes(&sig),
)
.is_ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChainResolveError {
InconsistentId { grant_id: String },
LeafMissing { grant_id: String },
AncestorMissing { parent_grant_id: String },
Cycle { grant_id: String },
UnreachableExtras { count: usize },
Unsigned { grant_id: String },
BadSignature { grant_id: String },
}
impl std::fmt::Display for ChainResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InconsistentId { grant_id } => {
write!(f, "grant {grant_id} declares an id that does not match its content")
}
Self::LeafMissing { grant_id } => {
write!(f, "the mandate names grant {grant_id}, which is not in the carried chain")
}
Self::AncestorMissing { parent_grant_id } => {
write!(f, "parent grant {parent_grant_id} is missing from the chain")
}
Self::Cycle { grant_id } => {
write!(f, "parent links revisit grant {grant_id}: the chain is a cycle")
}
Self::UnreachableExtras { count } => {
write!(f, "{count} carried grant(s) are not reachable from the mandate")
}
Self::Unsigned { grant_id } => {
write!(f, "grant {grant_id} carries no issuer signature")
}
Self::BadSignature { grant_id } => {
write!(f, "grant {grant_id} has a signature that does not verify")
}
}
}
}
impl std::error::Error for ChainResolveError {}
pub fn resolve_grant_chain(mandate: &Mandate) -> Result<Vec<Grant>, ChainResolveError> {
use std::collections::{HashMap, HashSet};
let mut by_id: HashMap<String, &Grant> = HashMap::new();
for g in &mandate.chain {
if !g.id_is_consistent() {
return Err(ChainResolveError::InconsistentId {
grant_id: g.grant_id.clone(),
});
}
let sig = match g.issuer_sig.as_deref() {
Some(s) if !s.is_empty() => s,
_ => {
return Err(ChainResolveError::Unsigned {
grant_id: g.grant_id.clone(),
})
}
};
if !g.verify_canonical(sig) {
return Err(ChainResolveError::BadSignature {
grant_id: g.grant_id.clone(),
});
}
by_id.insert(g.grant_id.clone(), g);
}
let mut leaf_first: Vec<Grant> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut cursor = Some(mandate.grant_id.clone());
while let Some(id) = cursor {
if !seen.insert(id.clone()) {
return Err(ChainResolveError::Cycle { grant_id: id });
}
let g = match by_id.get(&id) {
Some(g) => *g,
None => {
return Err(if leaf_first.is_empty() {
ChainResolveError::LeafMissing { grant_id: id }
} else {
ChainResolveError::AncestorMissing {
parent_grant_id: id,
}
})
}
};
leaf_first.push(g.clone());
cursor = g.parent_grant_id.clone();
}
if seen.len() != by_id.len() {
return Err(ChainResolveError::UnreachableExtras {
count: by_id.len() - seen.len(),
});
}
leaf_first.reverse(); Ok(leaf_first)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantChainError {
Empty,
BadTimestamp { index: usize },
ScopeWidened { parent: usize },
ExpiryWidened { parent: usize },
DepthNotIncremented { parent: usize },
DepthExceedsMax { parent: usize },
AudienceChanged { parent: usize },
}
impl std::fmt::Display for GrantChainError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "the chain is empty"),
Self::BadTimestamp { index } => {
write!(f, "grant at hop {index} has an unparseable issued_at/expiry")
}
Self::ScopeWidened { parent } => {
write!(f, "scope widens at hop {}->{}", parent, parent + 1)
}
Self::ExpiryWidened { parent } => {
write!(f, "expiry extends past the parent at hop {}->{}", parent, parent + 1)
}
Self::DepthNotIncremented { parent } => {
write!(f, "delegation depth does not increment by one at hop {}->{}", parent, parent + 1)
}
Self::DepthExceedsMax { parent } => {
write!(f, "delegation depth exceeds the parent's max_delegation at hop {}->{}", parent, parent + 1)
}
Self::AudienceChanged { parent } => {
write!(f, "audience changes at hop {}->{}", parent, parent + 1)
}
}
}
}
impl std::error::Error for GrantChainError {}
pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
if chain.is_empty() {
return Err(GrantChainError::Empty);
}
for (i, g) in chain.iter().enumerate() {
if parse_rfc3339_to_unix(&g.issued_at).is_none()
|| parse_rfc3339_to_unix(&g.expiry).is_none()
{
return Err(GrantChainError::BadTimestamp { index: i });
}
}
for (i, pair) in chain.windows(2).enumerate() {
let parent = &pair[0];
let child = &pair[1];
if !scope_subset(&child.scope, &parent.scope) {
return Err(GrantChainError::ScopeWidened { parent: i });
}
let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
if child_expiry > parent_expiry {
return Err(GrantChainError::ExpiryWidened { parent: i });
}
if child.delegation_depth != parent.delegation_depth + 1 {
return Err(GrantChainError::DepthNotIncremented { parent: i });
}
if child.delegation_depth > parent.max_delegation {
return Err(GrantChainError::DepthExceedsMax { parent: i });
}
if child.audience != parent.audience {
return Err(GrantChainError::AudienceChanged { parent: i });
}
}
Ok(())
}
fn scope_subset(child: &[String], parent: &[String]) -> bool {
child
.iter()
.all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
}
fn scope_entry_covers(parent: &str, child: &str) -> bool {
if parent == child {
return true;
}
if let Some(parent_prefix) = parent.strip_suffix(".*") {
let child_core = child.strip_suffix(".*").unwrap_or(child);
child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
#[test]
fn effect_confidence_ceiling_gates_on_independent_evidence() {
let with_evidence = Effect {
readback: Some("sha256:observed".into()),
effect_confidence: Some(EffectConfidence::Verified),
..Default::default()
};
assert!(with_evidence.has_independent_evidence());
assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
let claim_only = Effect {
output_hash: Some("sha256:out".into()),
effect_confidence: Some(EffectConfidence::Verified),
..Default::default()
};
assert!(!claim_only.has_independent_evidence());
assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
let honest_downgrade = Effect {
effect_confidence: Some(EffectConfidence::Unknown),
..Default::default()
};
assert_eq!(
honest_downgrade.evidence_ceiling(),
EffectConfidence::NotVerified
);
}
#[test]
fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
let e = Effect {
effect_confidence: Some(EffectConfidence::NotVerified),
..Default::default()
};
let j = serde_json::to_string(&e).unwrap();
assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
let empty = Effect::default();
assert!(!serde_json::to_string(&empty)
.unwrap()
.contains("effect_confidence"));
}
struct TrustingWitnessAuthority;
impl WitnessAuthority for TrustingWitnessAuthority {
fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
w.is_signed()
&& w.observer != actor
&& effect.readback.as_deref() == Some(w.observation.as_str())
}
}
#[test]
fn verify_effect_downgrades_unbacked_verified_claim() {
let mut s = good_stmt();
s.actor = "agent://worker".into();
s.effect = Some(Effect {
output_hash: Some("sha256:out".into()),
effect_confidence: Some(EffectConfidence::Verified),
..Default::default()
});
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
assert!(!v.is_verified());
assert!(
v.notes.iter().any(|n| n.contains("downgraded")),
"{:?}",
v.notes
);
}
#[test]
fn finality_and_confidence_are_independent_axes() {
let mut s = good_stmt();
s.effect = Some(Effect {
output_hash: Some("sha256:out".into()),
effect_confidence: Some(EffectConfidence::Partial),
finality: Some(EffectFinality::Finalized),
..Default::default()
});
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_confidence, EffectConfidence::Partial);
assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
assert_eq!(v.claimed_finality, Some(EffectFinality::Finalized));
}
#[test]
fn unbacked_finalized_is_downgraded_to_indeterminate() {
let mut s = good_stmt();
s.effect = Some(Effect {
output_hash: Some("sha256:out".into()),
finality: Some(EffectFinality::Finalized),
..Default::default()
});
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
assert!(
v.notes.iter().any(|n| n.contains("Finalized")),
"the downgrade must be stated, not silent: {:?}",
v.notes
);
}
#[test]
fn finalized_backed_by_readback_survives() {
let mut s = good_stmt();
s.effect = Some(Effect {
readback: Some("sha256:observed".into()),
finality: Some(EffectFinality::Finalized),
..Default::default()
});
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_finality, Some(EffectFinality::Finalized));
}
#[test]
fn lesser_finality_claims_pass_through_unchanged() {
for stage in [
EffectFinality::NotAttempted,
EffectFinality::Initiated,
EffectFinality::Failed,
EffectFinality::Indeterminate,
] {
let mut s = good_stmt();
s.effect = Some(Effect {
finality: Some(stage),
..Default::default()
});
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_finality, Some(stage), "{stage:?} was altered");
}
}
#[test]
fn not_attempted_is_the_no_authority_moved_receipt() {
let e = Effect {
input_hash: Some("sha256:req".into()),
finality: Some(EffectFinality::NotAttempted),
..Default::default()
};
assert!(EffectFinality::NotAttempted.is_resolved());
assert_eq!(check_resolution(&e, 4_000_000_000), ResolutionStatus::Resolved);
}
fn open_effect(resolution: Option<Resolution>) -> Effect {
Effect {
finality: Some(EffectFinality::Initiated),
resolution,
..Default::default()
}
}
#[test]
fn unresolved_without_a_deadline_reports_indefinite() {
assert_eq!(
check_resolution(&open_effect(None), 1_800_000_000),
ResolutionStatus::Indefinite
);
}
const DEADLINE: &str = "2026-07-20T11:00:00Z";
fn deadline_unix() -> i64 {
parse_rfc3339_to_unix(DEADLINE).expect("fixture deadline parses") as i64
}
#[test]
fn unresolved_past_its_deadline_reports_the_declared_event() {
let e = open_effect(Some(Resolution {
deadline: DEADLINE.into(),
on_deadline: DeadlineEvent::Escalate,
}));
match check_resolution(&e, deadline_unix() + 90) {
ResolutionStatus::Breached {
on_deadline,
seconds_overdue,
} => {
assert_eq!(on_deadline, DeadlineEvent::Escalate);
assert_eq!(seconds_overdue, 90);
}
other => panic!("expected Breached, got {other:?}"),
}
}
#[test]
fn unresolved_inside_its_window_is_pending() {
let e = open_effect(Some(Resolution {
deadline: DEADLINE.into(),
on_deadline: DeadlineEvent::Timeout,
}));
match check_resolution(&e, deadline_unix() - 60) {
ResolutionStatus::Pending { seconds_remaining } => {
assert_eq!(seconds_remaining, 60)
}
other => panic!("expected Pending, got {other:?}"),
}
}
#[test]
fn a_resolved_effect_cannot_breach() {
let e = Effect {
finality: Some(EffectFinality::Finalized),
resolution: Some(Resolution {
deadline: "2026-07-20T11:00:00Z".into(),
on_deadline: DeadlineEvent::Tombstone,
}),
..Default::default()
};
assert_eq!(check_resolution(&e, 4_000_000_000), ResolutionStatus::Resolved);
}
#[test]
fn unparseable_deadline_fails_toward_unknown() {
let e = open_effect(Some(Resolution {
deadline: "whenever".into(),
on_deadline: DeadlineEvent::Timeout,
}));
assert_eq!(
check_resolution(&e, 1_800_000_000),
ResolutionStatus::BadDeadline
);
}
#[test]
fn missing_finality_is_treated_as_unresolved() {
let e = Effect {
output_hash: Some("sha256:out".into()),
..Default::default()
};
assert_eq!(
check_resolution(&e, 1_800_000_000),
ResolutionStatus::Indefinite
);
}
#[test]
fn finality_and_resolution_are_omitted_when_absent() {
let json = serde_json::to_string(&Effect {
output_hash: Some("sha256:out".into()),
..Default::default()
})
.unwrap();
assert!(!json.contains("finality"), "{json}");
assert!(!json.contains("resolution"), "{json}");
}
#[test]
fn verify_effect_honors_verified_backed_by_readback() {
let mut s = good_stmt();
s.effect = Some(Effect {
readback: Some("sha256:observed".into()),
effect_confidence: Some(EffectConfidence::Verified),
..Default::default()
});
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_confidence, EffectConfidence::Verified);
assert!(v.is_verified());
}
#[test]
fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
let mut s = good_stmt();
s.actor = "agent://worker".into();
s.effect = Some(Effect {
readback: Some("sha256:state".into()),
effect_confidence: Some(EffectConfidence::Verified),
witnesses: vec![Witness {
observer: "agent://auditor".into(),
observation: "sha256:state".into(),
observed_at: Some("2026-07-20T10:00:00Z".into()),
signature: Some("ed25519:sig".into()),
}],
..Default::default()
});
let v = verify_effect(&s, &TrustingWitnessAuthority);
assert_eq!(v.trusted_witnesses, 1);
assert_eq!(v.effective_confidence, EffectConfidence::Verified);
let mut self_witness = s.clone();
if let Some(e) = self_witness.effect.as_mut() {
e.readback = None; e.witnesses[0].observer = "agent://worker".into();
}
let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
assert_eq!(v2.trusted_witnesses, 0);
assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
assert!(v2
.notes
.iter()
.any(|n| n.contains("not independently trusted")));
}
#[test]
fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
for c in [
EffectConfidence::Partial,
EffectConfidence::Ambiguous,
EffectConfidence::Unknown,
EffectConfidence::NotVerified,
] {
let mut s = good_stmt();
s.effect = Some(Effect {
effect_confidence: Some(c),
..Default::default()
});
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
}
}
#[test]
fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
let s = good_stmt();
assert!(s.effect.is_none());
let v = verify_effect(&s, &NoWitnessAuthority);
assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
assert_eq!(v.claimed_confidence, None);
assert!(v.notes.iter().any(|n| n.contains("no effect block")));
let mut s2 = good_stmt();
s2.effect = Some(Effect {
output_hash: Some("sha256:out".into()),
..Default::default()
});
let v2 = verify_effect(&s2, &NoWitnessAuthority);
assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
}
#[test]
fn witness_does_not_inflate_evidence_ceiling() {
let signed_witness = Witness {
observer: "agent://auditor".into(),
observation: "sha256:observed".into(),
observed_at: Some("2026-07-20T10:00:00Z".into()),
signature: Some("ed25519:sig".into()),
};
let e = Effect {
witnesses: vec![signed_witness.clone()],
effect_confidence: Some(EffectConfidence::Verified),
..Default::default()
};
assert!(!e.has_independent_evidence());
assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
assert!(signed_witness.is_signed());
assert_eq!(e.signed_witnesses().count(), 1);
let unsigned = Effect {
witnesses: vec![Witness {
observer: "agent://auditor".into(),
observation: "sha256:observed".into(),
..Default::default()
}],
..Default::default()
};
assert_eq!(unsigned.signed_witnesses().count(), 0);
}
#[test]
fn witnesses_serialize_and_omit_when_empty() {
let empty = Effect::default();
assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
let e = Effect {
witnesses: vec![Witness {
observer: "key_9f2c".into(),
observation: "sha256:obs".into(),
observed_at: None,
signature: Some("ed25519:sig".into()),
}],
..Default::default()
};
let j = serde_json::to_string(&e).unwrap();
assert!(j.contains("\"witnesses\":[{"), "{j}");
assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
assert!(!j.contains("observed_at"), "{j}");
let back: Effect = serde_json::from_str(&j).unwrap();
assert_eq!(back.witnesses.len(), 1);
assert!(back.witnesses[0].is_signed());
}
#[test]
fn runtime_identity_is_unbound_only_when_all_fields_absent() {
assert!(RuntimeIdentity::default().is_unbound());
let with_model = RuntimeIdentity {
model: Some("claude-opus-4-8".into()),
..Default::default()
};
assert!(!with_model.is_unbound());
let with_prompt = RuntimeIdentity {
system_prompt_hash: Some("sha256:sys".into()),
..Default::default()
};
assert!(!with_prompt.is_unbound());
}
#[test]
fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
let rt = RuntimeIdentity {
provider: Some("anthropic".into()),
model: Some("claude-opus-4-8".into()),
tool_schema_hash: Some("sha256:tools".into()),
system_prompt_hash: None,
};
let j = serde_json::to_string(&rt).unwrap();
assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
assert!(!j.contains("system_prompt_hash"), "{j}");
let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
assert_eq!(empty, "{}");
let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
assert!(back.is_unbound());
}
#[test]
fn runtime_is_omitted_from_statement_when_absent() {
let s = good_stmt();
assert!(s.runtime.is_none());
let j = serde_json::to_string(&s).unwrap();
assert!(!j.contains("runtime"), "{j}");
let mut with_rt = good_stmt();
with_rt.runtime = Some(RuntimeIdentity {
model: Some("claude-opus-4-8".into()),
..Default::default()
});
let j2 = serde_json::to_string(&with_rt).unwrap();
assert!(j2.contains("\"runtime\""), "{j2}");
let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
assert_eq!(
back.runtime.unwrap().model.as_deref(),
Some("claude-opus-4-8")
);
}
fn base_mandate() -> Mandate {
Mandate {
grant_id: "grant_9c2f".into(),
grantor: "key_parent".into(),
issuer_sig: None,
objective_hash: Some("sha256:abc".into()),
scope: vec!["payments.charge".into()],
audience: "acme-payments-api".into(),
parent_request_id: Some("req_7d3e".into()),
delegation_depth: 2,
issued_at: "2026-07-11T19:50:00Z".into(),
expiry: "2026-07-11T20:50:00Z".into(),
max_delegation: 3,
revocation: Revocation {
path: "hub://acme/revocations".into(),
revoked_at: None,
},
chain: Vec::new(),
}
}
fn good_stmt() -> ActionStatementV2 {
let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
s.timestamp = "2026-07-11T19:53:09Z".into();
s.audience = Some("acme-payments-api".into());
s
}
struct StaticRevocation(RevocationStatus);
impl RevocationSource for StaticRevocation {
fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
self.0.clone()
}
}
#[test]
fn scope_exact_and_glob() {
assert!(action_in_scope(
"payments.charge",
&["payments.charge".into()]
));
assert!(action_in_scope("payments.charge", &["payments.*".into()]));
assert!(action_in_scope("payments", &["payments.*".into()]));
assert!(!action_in_scope(
"payments.refund",
&["payments.charge".into()]
));
assert!(!action_in_scope("email.send", &["payments.*".into()]));
assert!(!action_in_scope("anything", &["*".into()]));
assert!(action_in_scope("*", &["*".into()]));
}
#[test]
fn empty_scope_authorizes_nothing() {
let mut s = good_stmt();
s.mandate.scope = vec![];
match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
v => panic!("empty scope must fail, got {v:?}"),
}
}
#[test]
fn action_out_of_scope_fails() {
let mut s = good_stmt();
s.action = "payments.refund".into();
assert!(matches!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Fail(_)
));
}
#[test]
fn audience_match_passes_layer() {
let s = good_stmt();
assert_eq!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Pass
);
}
#[test]
fn audience_mismatch_fails() {
let mut s = good_stmt();
s.audience = Some("evil-api".into());
assert!(matches!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Fail(_)
));
}
#[test]
fn missing_action_audience_is_unverified_not_pass() {
let mut s = good_stmt();
s.audience = None;
match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
MandateVerdict::Unverified(rs) => {
assert!(rs.iter().any(|r| r.contains("recorded no audience")))
}
v => panic!("missing audience must be Unverified, got {v:?}"),
}
}
#[test]
fn empty_mandate_audience_fails() {
let mut s = good_stmt();
s.mandate.audience = "".into();
assert!(matches!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Fail(_)
));
}
#[test]
fn signed_before_issued_fails() {
let mut s = good_stmt();
s.timestamp = "2026-07-11T19:49:59Z".into(); assert!(matches!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Fail(_)
));
}
#[test]
fn signed_at_expiry_fails() {
let mut s = good_stmt();
s.timestamp = "2026-07-11T20:50:00Z".into(); assert!(matches!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Fail(_)
));
}
#[test]
fn signed_within_window_passes() {
let s = good_stmt(); assert_eq!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Pass
);
}
#[test]
fn malformed_timestamp_fails_closed() {
let mut s = good_stmt();
s.timestamp = "not-a-timestamp".into();
assert!(matches!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Fail(_)
));
}
#[test]
fn revoked_after_signing_still_passes() {
let s = good_stmt();
let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
}
#[test]
fn revoked_before_signing_fails() {
let s = good_stmt(); let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
}
#[test]
fn revocation_unknown_is_unverified() {
let s = good_stmt();
match verify_mandate(&s, &NoRevocationSource) {
MandateVerdict::Unverified(rs) => {
assert!(rs
.iter()
.any(|r| r.contains("revocation could not be checked")))
}
v => panic!("no revocation source must be Unverified, got {v:?}"),
}
}
#[test]
fn fail_takes_precedence_over_unverified() {
let mut s = good_stmt();
s.action = "payments.refund".into();
assert!(matches!(
verify_mandate(&s, &NoRevocationSource),
MandateVerdict::Fail(_)
));
}
#[test]
fn wrong_type_fails() {
let mut s = good_stmt();
s.type_ = "treeship/action/v1".into();
assert!(matches!(
verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
MandateVerdict::Fail(_)
));
}
#[test]
fn mandate_is_bound_into_signature() {
let signer = Ed25519Signer::generate("key_test").unwrap();
let pt = payload_type_v2("action");
let a = good_stmt();
let mut b = good_stmt();
b.mandate.scope = vec!["payments.*".into()];
let ra = sign(&pt, &a, &signer).unwrap();
let rb = sign(&pt, &b, &signer).unwrap();
assert_ne!(
ra.artifact_id, rb.artifact_id,
"changing mandate.scope must change the signed artifact id"
);
}
#[test]
fn v2_sign_verify_roundtrip() {
let signer = Ed25519Signer::generate("key_test").unwrap();
let verifier = EnvVerifier::from_signer(&signer);
let pt = payload_type_v2("action");
let mut s = good_stmt();
s.effect = Some(Effect {
output_hash: Some("sha256:out".into()),
readback: Some("sha256:observed".into()),
bytes_moved: Some(1_048_576),
cost: Some(Cost {
unit: "usd_micros".into(),
amount: 4200,
}),
side_effects: vec!["db:users.update".into()],
..Default::default()
});
let signed = sign(&pt, &s, &signer).unwrap();
verifier.verify(&signed.envelope).unwrap();
let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
assert_eq!(decoded.type_, TYPE_ACTION_V2);
assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
}
#[test]
fn v2_payload_type_differs_from_v1() {
assert_eq!(
payload_type_v2("action"),
"application/vnd.treeship.action.v2+json"
);
assert_ne!(
payload_type_v2("action"),
super::super::payload_type("action")
);
}
fn grant(
id: &str,
grantor: &str,
scope: &[&str],
depth: u32,
expiry: &str,
max_deleg: u32,
) -> Grant {
Grant {
grant_id: id.into(),
grantor: grantor.into(),
issuer_sig: None,
scope: scope.iter().map(|s| (*s).into()).collect(),
audience: "acme-payments-api".into(),
parent_request_id: None,
parent_grant_id: None,
delegation_depth: depth,
issued_at: "2026-07-11T19:00:00Z".into(),
expiry: expiry.into(),
max_delegation: max_deleg,
objective_hash: None,
}
}
#[test]
fn grant_sign_verify_roundtrip_and_tamper() {
let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
let mut g = grant(
"grant_root",
&grantor,
&["payments.*"],
0,
"2026-07-11T21:00:00Z",
3,
);
g.grant_id = g.derive_grant_id();
let sig = g.sign_canonical(&signer).unwrap();
assert!(g.verify_canonical(&sig));
g.scope.push("email.*".into());
assert!(!g.verify_canonical(&sig));
}
#[test]
fn grant_verify_rejects_wrong_key() {
let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
let g = grant(
"grant_root",
&grantor,
&["payments.*"],
0,
"2026-07-11T21:00:00Z",
3,
);
let sig = g.sign_canonical(&attacker).unwrap();
assert!(!g.verify_canonical(&sig));
}
#[test]
fn valid_attenuating_chain_ok() {
let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
let child = grant(
"g1",
"k",
&["payments.charge"],
1,
"2026-07-11T20:30:00Z",
3,
);
assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
}
#[test]
fn scope_widening_rejected() {
let root = grant(
"g0",
"k",
&["payments.charge"],
0,
"2026-07-11T21:00:00Z",
3,
);
let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
assert_eq!(
verify_grant_chain(&[root, child]),
Err(GrantChainError::ScopeWidened { parent: 0 })
);
}
#[test]
fn expiry_widening_rejected() {
let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
let child = grant(
"g1",
"k",
&["payments.charge"],
1,
"2026-07-11T22:00:00Z",
3,
);
assert_eq!(
verify_grant_chain(&[root, child]),
Err(GrantChainError::ExpiryWidened { parent: 0 })
);
}
#[test]
fn depth_not_incremented_rejected() {
let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
let child = grant(
"g1",
"k",
&["payments.charge"],
2,
"2026-07-11T21:00:00Z",
3,
);
assert_eq!(
verify_grant_chain(&[root, child]),
Err(GrantChainError::DepthNotIncremented { parent: 0 })
);
}
#[test]
fn depth_exceeds_max_rejected() {
let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
let child = grant(
"g1",
"k",
&["payments.charge"],
1,
"2026-07-11T21:00:00Z",
0,
);
assert_eq!(
verify_grant_chain(&[root, child]),
Err(GrantChainError::DepthExceedsMax { parent: 0 })
);
}
#[test]
fn audience_change_rejected() {
let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
let mut child = grant(
"g1",
"k",
&["payments.charge"],
1,
"2026-07-11T21:00:00Z",
3,
);
child.audience = "other-api".into();
assert_eq!(
verify_grant_chain(&[root, child]),
Err(GrantChainError::AudienceChanged { parent: 0 })
);
}
#[test]
fn empty_chain_rejected() {
assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
}
#[test]
fn bad_timestamp_in_chain_rejected() {
let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
root.expiry = "nope".into();
assert_eq!(
verify_grant_chain(&[root]),
Err(GrantChainError::BadTimestamp { index: 0 })
);
}
#[test]
fn single_grant_chain_ok() {
let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
assert_eq!(verify_grant_chain(&[root]), Ok(()));
}
fn mk_grant(
signer: &Ed25519Signer,
grantor_pk: &str,
scope: Vec<&str>,
depth: u32,
parent: Option<&str>,
) -> Grant {
let mut g = Grant {
grant_id: String::new(),
grantor: grantor_pk.to_string(),
issuer_sig: None,
scope: scope.into_iter().map(String::from).collect(),
audience: "acme".into(),
parent_request_id: None,
parent_grant_id: parent.map(String::from),
delegation_depth: depth,
issued_at: "2026-07-20T10:00:00Z".into(),
expiry: "2026-07-20T11:00:00Z".into(),
max_delegation: 3,
objective_hash: None,
};
g.grant_id = g.derive_grant_id();
g.issuer_sig = Some(g.sign_canonical(signer).unwrap());
g
}
fn chain_fixture() -> (Grant, Grant, Ed25519Signer) {
let signer = Ed25519Signer::generate("issuer").unwrap();
let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
let root = mk_grant(&signer, &pk, vec!["payments.*"], 0, None);
let leaf = mk_grant(
&signer,
&pk,
vec!["payments.charge"],
1,
Some(&root.grant_id),
);
(root, leaf, signer)
}
fn mandate_with(leaf: &Grant, chain: Vec<Grant>) -> Mandate {
let mut m = base_mandate();
m.grant_id = leaf.grant_id.clone();
m.chain = chain;
m
}
#[test]
fn grant_id_is_content_derived_and_stable() {
let (root, _, _) = chain_fixture();
assert!(root.grant_id.starts_with("grn_"));
assert_eq!(root.grant_id, root.derive_grant_id());
let mut altered = root.clone();
altered.scope = vec!["payments.refund".into()];
assert_ne!(altered.derive_grant_id(), root.grant_id);
}
#[test]
fn hand_chosen_id_fails_verification() {
let (mut root, _, _) = chain_fixture();
let sig = root.issuer_sig.clone().unwrap();
root.grant_id = "grn_deadbeefdeadbeef".into();
assert!(
!root.verify_canonical(&sig),
"an id that was chosen rather than computed must not verify"
);
}
#[test]
fn resolves_root_first_regardless_of_carrier_order() {
let (root, leaf, _) = chain_fixture();
let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
let resolved = resolve_grant_chain(&m).expect("resolves");
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].grant_id, root.grant_id, "root must come first");
assert_eq!(resolved[1].grant_id, leaf.grant_id);
}
#[test]
fn truncated_chain_is_rejected() {
let (_, leaf, _) = chain_fixture();
let m = mandate_with(&leaf, vec![leaf.clone()]);
match resolve_grant_chain(&m) {
Err(ChainResolveError::AncestorMissing { .. }) => {}
other => panic!("truncation must be caught, got {other:?}"),
}
}
#[test]
fn spliced_decoy_grant_is_rejected() {
let (root, leaf, signer) = chain_fixture();
let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
let decoy = mk_grant(&signer, &pk, vec!["email.send"], 0, None);
assert_ne!(decoy.grant_id, root.grant_id);
let m = mandate_with(&leaf, vec![root.clone(), leaf.clone(), decoy]);
match resolve_grant_chain(&m) {
Err(ChainResolveError::UnreachableExtras { count }) => assert_eq!(count, 1),
other => panic!("unreachable extras must be refused, got {other:?}"),
}
}
#[test]
fn unsigned_ancestor_is_rejected() {
let (mut root, leaf, _) = chain_fixture();
root.issuer_sig = None;
let m = mandate_with(&leaf, vec![root, leaf.clone()]);
assert!(matches!(
resolve_grant_chain(&m),
Err(ChainResolveError::Unsigned { .. })
));
}
#[test]
fn resolved_chain_feeds_attenuation_check() {
let (root, leaf, _) = chain_fixture();
let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
let resolved = resolve_grant_chain(&m).expect("resolves");
assert!(
verify_grant_chain(&resolved).is_ok(),
"narrowing scope at depth+1 must satisfy attenuation"
);
}
#[test]
fn leaf_not_in_chain_is_rejected() {
let (root, leaf, _) = chain_fixture();
let mut m = mandate_with(&leaf, vec![root.clone()]);
m.grant_id = leaf.grant_id.clone();
match resolve_grant_chain(&m) {
Err(ChainResolveError::LeafMissing { grant_id }) => {
assert_eq!(grant_id, leaf.grant_id);
}
other => panic!("a mandate naming an absent leaf must fail, got {other:?}"),
}
}
#[test]
fn ancestor_signed_by_a_stranger_is_rejected() {
let (root, leaf, _) = chain_fixture();
let stranger = Ed25519Signer::generate("stranger").unwrap();
let mut forged = root.clone();
forged.issuer_sig = Some(forged.sign_canonical(&stranger).unwrap());
assert_eq!(
forged.grant_id, root.grant_id,
"signing with another key must not change the content id"
);
let m = mandate_with(&leaf, vec![forged, leaf.clone()]);
match resolve_grant_chain(&m) {
Err(ChainResolveError::BadSignature { grant_id }) => {
assert_eq!(grant_id, root.grant_id);
}
other => panic!("a grant signed by a non-grantor must fail, got {other:?}"),
}
}
#[test]
fn inconsistent_id_is_caught_before_signature_check() {
let (root, leaf, _) = chain_fixture();
let mut tampered = root.clone();
tampered.grant_id = "grn_0000000000000000".into();
let m = mandate_with(&leaf, vec![tampered, leaf.clone()]);
assert!(matches!(
resolve_grant_chain(&m),
Err(ChainResolveError::InconsistentId { .. })
));
}
}