1use serde::{Deserialize, Serialize};
41
42use super::invitation::{canonical_json_digest, parse_rfc3339_to_unix};
43use super::SubjectRef;
44use crate::attestation::{Signer, SignerError};
45use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
46use ed25519_dalek::{Signature, VerifyingKey};
47use sha2::{Digest, Sha256};
48
49pub const TYPE_ACTION_V2: &str = "treeship/action/v2";
51
52pub fn payload_type_v2(suffix: &str) -> String {
59 format!("application/vnd.treeship.{}.v2+json", suffix)
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Revocation {
77 pub path: String,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub revoked_at: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Mandate {
89 pub grant_id: String,
91
92 pub grantor: String,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub issuer_sig: Option<String>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub objective_hash: Option<String>,
106
107 #[serde(default)]
110 pub scope: Vec<String>,
111
112 pub audience: String,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub parent_request_id: Option<String>,
119
120 #[serde(default)]
123 pub delegation_depth: u32,
124
125 pub issued_at: String,
127
128 pub expiry: String,
130
131 #[serde(default)]
133 pub max_delegation: u32,
134
135 pub revocation: Revocation,
137
138 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub grantee: Option<String>,
147
148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub chain: Vec<Grant>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct Cost {
163 pub unit: String,
164 pub amount: u64,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
182pub struct Witness {
183 pub observer: String,
187 pub observation: String,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub observed_at: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub signature: Option<String>,
200}
201
202impl Witness {
203 pub fn is_signed(&self) -> bool {
208 self.signature.is_some()
209 }
210}
211
212#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
217pub struct Effect {
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub input_hash: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub output_hash: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub readback: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub bytes_moved: Option<u64>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub cost: Option<Cost>,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub side_effects: Vec<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub context_snapshot: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub effect_confidence: Option<EffectConfidence>,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub witnesses: Vec<Witness>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub finality: Option<EffectFinality>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub resolution: Option<Resolution>,
267}
268
269#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "snake_case")]
280pub enum EffectConfidence {
281 Verified,
284 Partial,
287 Ambiguous,
289 Unknown,
291 NotVerified,
294}
295
296#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum EffectFinality {
309 NotAttempted,
314 Initiated,
317 Finalized,
319 Failed,
321 Indeterminate,
325}
326
327impl EffectFinality {
328 pub fn is_resolved(self) -> bool {
332 matches!(self, Self::NotAttempted | Self::Finalized | Self::Failed)
333 }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct Resolution {
345 pub deadline: String,
347 pub on_deadline: DeadlineEvent,
349}
350
351#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum DeadlineEvent {
355 Timeout,
357 Escalate,
359 Tombstone,
361 Inherit,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
367pub enum ResolutionStatus {
368 Resolved,
370 Indefinite,
374 Pending { seconds_remaining: i64 },
376 Breached {
379 on_deadline: DeadlineEvent,
380 seconds_overdue: i64,
381 },
382 BadDeadline,
385}
386
387pub fn check_resolution(effect: &Effect, now_unix: i64) -> ResolutionStatus {
399 let resolved = effect
400 .finality
401 .map(EffectFinality::is_resolved)
402 .unwrap_or(false);
403 if resolved {
404 return ResolutionStatus::Resolved;
405 }
406
407 let res = match &effect.resolution {
408 Some(r) => r,
409 None => return ResolutionStatus::Indefinite,
410 };
411
412 let deadline = match parse_rfc3339_to_unix(&res.deadline) {
415 Some(t) if t <= i64::MAX as u64 => t as i64,
416 _ => return ResolutionStatus::BadDeadline,
417 };
418
419 if now_unix > deadline {
420 ResolutionStatus::Breached {
421 on_deadline: res.on_deadline,
422 seconds_overdue: now_unix - deadline,
423 }
424 } else {
425 ResolutionStatus::Pending {
426 seconds_remaining: deadline - now_unix,
427 }
428 }
429}
430
431impl Effect {
432 pub fn has_independent_evidence(&self) -> bool {
443 self.readback.is_some()
444 }
445
446 pub fn signed_witnesses(&self) -> impl Iterator<Item = &Witness> {
450 self.witnesses.iter().filter(|w| w.is_signed())
451 }
452
453 pub fn evidence_ceiling(&self) -> EffectConfidence {
459 if self.has_independent_evidence() {
460 EffectConfidence::Verified
461 } else {
462 EffectConfidence::NotVerified
463 }
464 }
465}
466
467#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
482pub struct RuntimeIdentity {
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub provider: Option<String>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub model: Option<String>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub tool_schema_hash: Option<String>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub system_prompt_hash: Option<String>,
495}
496
497impl RuntimeIdentity {
498 pub fn is_unbound(&self) -> bool {
503 self.provider.is_none()
504 && self.model.is_none()
505 && self.tool_schema_hash.is_none()
506 && self.system_prompt_hash.is_none()
507 }
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct ActionStatementV2 {
516 #[serde(rename = "type")]
517 pub type_: String,
518
519 pub timestamp: String,
522
523 pub actor: String,
524 pub action: String,
525
526 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub audience: Option<String>,
532
533 #[serde(default, skip_serializing_if = "subject_is_empty")]
534 pub subject: SubjectRef,
535
536 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
537 pub parent_id: Option<String>,
538
539 pub mandate: Mandate,
540
541 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub effect: Option<Effect>,
543
544 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub runtime: Option<RuntimeIdentity>,
549
550 #[serde(skip_serializing_if = "Option::is_none")]
551 pub meta: Option<serde_json::Value>,
552}
553
554fn subject_is_empty(s: &SubjectRef) -> bool {
555 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
556}
557
558impl ActionStatementV2 {
559 pub fn new(actor: impl Into<String>, action: impl Into<String>, mandate: Mandate) -> Self {
561 Self {
562 type_: TYPE_ACTION_V2.into(),
563 timestamp: super::unix_to_rfc3339(now_unix()),
564 actor: actor.into(),
565 action: action.into(),
566 audience: None,
567 subject: SubjectRef::default(),
568 parent_id: None,
569 mandate,
570 effect: None,
571 runtime: None,
572 meta: None,
573 }
574 }
575}
576
577fn now_unix() -> u64 {
578 use std::time::{SystemTime, UNIX_EPOCH};
579 SystemTime::now()
580 .duration_since(UNIX_EPOCH)
581 .unwrap_or_default()
582 .as_secs()
583}
584
585pub fn action_in_scope(action: &str, scope: &[String]) -> bool {
595 scope.iter().any(|entry| scope_entry_matches(entry, action))
596}
597
598fn scope_entry_matches(entry: &str, action: &str) -> bool {
599 if let Some(prefix) = entry.strip_suffix(".*") {
600 action == prefix || action.starts_with(&format!("{prefix}."))
601 } else {
602 entry == action
603 }
604}
605
606#[derive(Debug, Clone, PartialEq, Eq)]
612pub enum RevocationStatus {
613 NotRevoked,
615 RevokedAt(String),
617 Unknown(String),
620}
621
622pub trait RevocationSource {
628 fn status(&self, grant_id: &str, path: &str) -> RevocationStatus;
629}
630
631pub struct NoRevocationSource;
633
634impl RevocationSource for NoRevocationSource {
635 fn status(&self, _grant_id: &str, path: &str) -> RevocationStatus {
636 RevocationStatus::Unknown(format!("no revocation source configured for path '{path}'"))
637 }
638}
639
640#[derive(Debug, Clone, PartialEq, Eq)]
650pub enum MandateVerdict {
651 Pass,
652 Unverified(Vec<String>),
653 Fail(Vec<String>),
654}
655
656impl MandateVerdict {
657 pub fn is_pass(&self) -> bool {
658 matches!(self, MandateVerdict::Pass)
659 }
660}
661
662pub fn verify_mandate(
673 stmt: &ActionStatementV2,
674 revocation: &dyn RevocationSource,
675) -> MandateVerdict {
676 let mut fail: Vec<String> = Vec::new();
677 let mut unver: Vec<String> = Vec::new();
678
679 if stmt.type_ != TYPE_ACTION_V2 {
680 return MandateVerdict::Fail(vec![format!(
681 "statement type '{}' is not {TYPE_ACTION_V2}",
682 stmt.type_
683 )]);
684 }
685
686 let m = &stmt.mandate;
687
688 let signed_at = match parse_rfc3339_to_unix(&stmt.timestamp) {
691 Some(t) => t,
692 None => {
693 return MandateVerdict::Fail(vec![format!(
694 "timestamp '{}' is not RFC 3339",
695 stmt.timestamp
696 )])
697 }
698 };
699
700 if m.scope.is_empty() {
702 fail.push("mandate.scope is empty: it authorizes no action".into());
703 } else if !action_in_scope(&stmt.action, &m.scope) {
704 fail.push(format!(
705 "action '{}' is not in mandate scope {:?}",
706 stmt.action, m.scope
707 ));
708 }
709
710 if m.audience.trim().is_empty() {
712 fail.push("mandate.audience is empty: the grant is not bound to an audience".into());
713 } else {
714 match &stmt.audience {
715 Some(a) if a == &m.audience => {}
716 Some(a) => fail.push(format!(
717 "action audience '{a}' does not match mandate audience '{}'",
718 m.audience
719 )),
720 None => unver
721 .push("action recorded no audience; cannot confirm it matched the mandate".into()),
722 }
723 }
724
725 match (
727 parse_rfc3339_to_unix(&m.issued_at),
728 parse_rfc3339_to_unix(&m.expiry),
729 ) {
730 (Some(issued), Some(expiry)) => {
731 if expiry <= issued {
732 fail.push(format!(
733 "mandate expiry '{}' is not after issued_at '{}'",
734 m.expiry, m.issued_at
735 ));
736 }
737 if signed_at < issued {
738 fail.push(format!(
739 "signed_at '{}' is before mandate issued_at '{}'",
740 stmt.timestamp, m.issued_at
741 ));
742 }
743 if signed_at >= expiry {
744 fail.push(format!(
745 "signed_at '{}' is at or after mandate expiry '{}'",
746 stmt.timestamp, m.expiry
747 ));
748 }
749 }
750 _ => fail.push(format!(
751 "mandate issued_at '{}' / expiry '{}' are not both RFC 3339",
752 m.issued_at, m.expiry
753 )),
754 }
755
756 match revocation.status(&m.grant_id, &m.revocation.path) {
759 RevocationStatus::NotRevoked => {}
760 RevocationStatus::RevokedAt(ts) => match parse_rfc3339_to_unix(&ts) {
761 Some(revoked_at) => {
762 if signed_at >= revoked_at {
763 fail.push(format!(
764 "grant was revoked at '{ts}'; signed_at '{}' is not before revocation",
765 stmt.timestamp
766 ));
767 }
768 }
769 None => unver.push(format!("revocation timestamp '{ts}' is not RFC 3339")),
770 },
771 RevocationStatus::Unknown(reason) => {
772 unver.push(format!("revocation could not be checked: {reason}"))
773 }
774 }
775
776 if m.grantee.as_deref().unwrap_or("").is_empty() {
786 unver.push(
787 "grant names no grantee (bearer): any holder of the grant could have produced this"
788 .into(),
789 );
790 }
791
792 if !fail.is_empty() {
793 MandateVerdict::Fail(fail)
794 } else if !unver.is_empty() {
795 MandateVerdict::Unverified(unver)
796 } else {
797 MandateVerdict::Pass
798 }
799}
800
801pub trait WitnessAuthority {
814 fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
815}
816
817pub struct NoWitnessAuthority;
820
821impl WitnessAuthority for NoWitnessAuthority {
822 fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
823 false
824 }
825}
826
827#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct EffectVerdict {
834 pub effective_confidence: EffectConfidence,
840 pub claimed_confidence: Option<EffectConfidence>,
843 pub trusted_witnesses: usize,
845 pub notes: Vec<String>,
847 pub effective_finality: Option<EffectFinality>,
853 pub claimed_finality: Option<EffectFinality>,
855}
856
857impl EffectVerdict {
858 pub fn is_verified(&self) -> bool {
860 self.effective_confidence == EffectConfidence::Verified
861 }
862}
863
864pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
879 let effect = match &stmt.effect {
880 Some(e) => e,
881 None => {
882 return EffectVerdict {
883 effective_confidence: EffectConfidence::NotVerified,
884 claimed_confidence: None,
885 trusted_witnesses: 0,
886 notes: vec!["receipt carries no effect block; effect is unverified".into()],
887 effective_finality: None,
888 claimed_finality: None,
889 }
890 }
891 };
892
893 let mut notes: Vec<String> = Vec::new();
894
895 let trusted_witnesses = effect
896 .witnesses
897 .iter()
898 .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
899 .count();
900 let untrusted = effect.witnesses.len() - trusted_witnesses;
901 if untrusted > 0 {
902 notes.push(format!(
903 "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
904 effect.witnesses.len()
905 ));
906 }
907
908 let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
911
912 let claimed = effect.effect_confidence;
913 let effective = match claimed {
914 None => {
915 notes.push("actor recorded no effect_confidence; effect is unverified".into());
916 EffectConfidence::NotVerified
917 }
918 Some(EffectConfidence::Verified) if !has_evidence => {
919 notes.push(
920 "actor claimed Verified but bundled no independent evidence \
921 (no readback, no trusted witness); downgraded to NotVerified"
922 .into(),
923 );
924 EffectConfidence::NotVerified
925 }
926 Some(c) => c,
927 };
928
929 let claimed_finality = effect.finality;
939 let effective_finality = match claimed_finality {
940 Some(EffectFinality::Finalized) if !has_evidence => {
941 notes.push(
942 "actor claimed the effect Finalized but bundled no independent evidence \
943 (no readback, no trusted witness); downgraded to Indeterminate"
944 .into(),
945 );
946 Some(EffectFinality::Indeterminate)
947 }
948 other => other,
949 };
950
951 EffectVerdict {
952 effective_confidence: effective,
953 claimed_confidence: claimed,
954 trusted_witnesses,
955 notes,
956 effective_finality,
957 claimed_finality,
958 }
959}
960
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971pub struct Grant {
972 pub grant_id: String,
973 pub grantor: String,
975 #[serde(default)]
976 pub scope: Vec<String>,
977 pub audience: String,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub parent_request_id: Option<String>,
980 #[serde(default)]
981 pub delegation_depth: u32,
982 pub issued_at: String,
983 pub expiry: String,
984 #[serde(default)]
985 pub max_delegation: u32,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub objective_hash: Option<String>,
988
989 #[serde(default, skip_serializing_if = "Option::is_none")]
994 pub issuer_sig: Option<String>,
995
996 #[serde(default, skip_serializing_if = "Option::is_none")]
1001 pub parent_grant_id: Option<String>,
1002
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1016 pub grantee: Option<String>,
1017}
1018
1019impl Grant {
1020 pub fn canonical_for_signing(&self) -> String {
1025 let scope_digest = canonical_json_digest(&self.scope);
1026 format!(
1037 "v3|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
1038 self.grantor,
1039 scope_digest,
1040 self.audience,
1041 self.parent_request_id.as_deref().unwrap_or(""),
1042 self.parent_grant_id.as_deref().unwrap_or(""),
1043 self.grantee.as_deref().unwrap_or(""),
1044 self.delegation_depth,
1045 self.issued_at,
1046 self.expiry,
1047 self.max_delegation,
1048 self.objective_hash.as_deref().unwrap_or(""),
1049 )
1050 }
1051
1052 pub fn binds_holder(&self) -> bool {
1059 self.grantee.as_deref().is_some_and(|g| !g.is_empty())
1060 }
1061
1062 pub fn exercisable_by(&self, holder_pubkey: &str) -> bool {
1066 match self.grantee.as_deref() {
1067 Some(g) if !g.is_empty() => g == holder_pubkey,
1068 _ => true,
1069 }
1070 }
1071
1072 pub fn derive_grant_id(&self) -> String {
1079 let digest = Sha256::digest(self.canonical_for_signing().as_bytes());
1080 format!("grn_{}", hex::encode(&digest[..8]))
1081 }
1082
1083 pub fn id_is_consistent(&self) -> bool {
1087 self.grant_id == self.derive_grant_id()
1088 }
1089
1090 pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
1094 let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
1095 Ok(URL_SAFE_NO_PAD.encode(sig))
1096 }
1097
1098 pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
1103 if !self.id_is_consistent() {
1107 return false;
1108 }
1109 let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
1110 Ok(b) if b.len() == 32 => b,
1111 _ => return false,
1112 };
1113 let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
1114 Ok(b) if b.len() == 64 => b,
1115 _ => return false,
1116 };
1117 let mut pk = [0u8; 32];
1118 pk.copy_from_slice(&pk_bytes);
1119 let mut sig = [0u8; 64];
1120 sig.copy_from_slice(&sig_bytes);
1121 let vk = match VerifyingKey::from_bytes(&pk) {
1122 Ok(k) => k,
1123 Err(_) => return false,
1124 };
1125 vk.verify_strict(
1126 self.canonical_for_signing().as_bytes(),
1127 &Signature::from_bytes(&sig),
1128 )
1129 .is_ok()
1130 }
1131}
1132
1133#[derive(Debug, Clone, PartialEq, Eq)]
1135pub enum ChainResolveError {
1136 InconsistentId { grant_id: String },
1138 LeafMissing { grant_id: String },
1140 AncestorMissing { parent_grant_id: String },
1142 Cycle { grant_id: String },
1144 UnreachableExtras { count: usize },
1148 Unsigned { grant_id: String },
1150 BadSignature { grant_id: String },
1152}
1153
1154impl std::fmt::Display for ChainResolveError {
1155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1159 match self {
1160 Self::InconsistentId { grant_id } => {
1161 write!(
1162 f,
1163 "grant {grant_id} declares an id that does not match its content"
1164 )
1165 }
1166 Self::LeafMissing { grant_id } => {
1167 write!(
1168 f,
1169 "the mandate names grant {grant_id}, which is not in the carried chain"
1170 )
1171 }
1172 Self::AncestorMissing { parent_grant_id } => {
1173 write!(
1174 f,
1175 "parent grant {parent_grant_id} is missing from the chain"
1176 )
1177 }
1178 Self::Cycle { grant_id } => {
1179 write!(
1180 f,
1181 "parent links revisit grant {grant_id}: the chain is a cycle"
1182 )
1183 }
1184 Self::UnreachableExtras { count } => {
1185 write!(
1186 f,
1187 "{count} carried grant(s) are not reachable from the mandate"
1188 )
1189 }
1190 Self::Unsigned { grant_id } => {
1191 write!(f, "grant {grant_id} carries no issuer signature")
1192 }
1193 Self::BadSignature { grant_id } => {
1194 write!(f, "grant {grant_id} has a signature that does not verify")
1195 }
1196 }
1197 }
1198}
1199
1200impl std::error::Error for ChainResolveError {}
1201
1202pub fn resolve_grant_chain(mandate: &Mandate) -> Result<Vec<Grant>, ChainResolveError> {
1215 use std::collections::{HashMap, HashSet};
1216
1217 let mut by_id: HashMap<String, &Grant> = HashMap::new();
1219 for g in &mandate.chain {
1220 if !g.id_is_consistent() {
1221 return Err(ChainResolveError::InconsistentId {
1222 grant_id: g.grant_id.clone(),
1223 });
1224 }
1225 let sig = match g.issuer_sig.as_deref() {
1226 Some(s) if !s.is_empty() => s,
1227 _ => {
1228 return Err(ChainResolveError::Unsigned {
1229 grant_id: g.grant_id.clone(),
1230 })
1231 }
1232 };
1233 if !g.verify_canonical(sig) {
1234 return Err(ChainResolveError::BadSignature {
1235 grant_id: g.grant_id.clone(),
1236 });
1237 }
1238 by_id.insert(g.grant_id.clone(), g);
1239 }
1240
1241 let mut leaf_first: Vec<Grant> = Vec::new();
1243 let mut seen: HashSet<String> = HashSet::new();
1244 let mut cursor = Some(mandate.grant_id.clone());
1245
1246 while let Some(id) = cursor {
1247 if !seen.insert(id.clone()) {
1248 return Err(ChainResolveError::Cycle { grant_id: id });
1249 }
1250 let g = match by_id.get(&id) {
1251 Some(g) => *g,
1252 None => {
1253 return Err(if leaf_first.is_empty() {
1254 ChainResolveError::LeafMissing { grant_id: id }
1255 } else {
1256 ChainResolveError::AncestorMissing {
1257 parent_grant_id: id,
1258 }
1259 })
1260 }
1261 };
1262 leaf_first.push(g.clone());
1263 cursor = g.parent_grant_id.clone();
1264 }
1265
1266 if seen.len() != by_id.len() {
1268 return Err(ChainResolveError::UnreachableExtras {
1269 count: by_id.len() - seen.len(),
1270 });
1271 }
1272
1273 leaf_first.reverse(); Ok(leaf_first)
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq)]
1279pub enum GrantChainError {
1280 Empty,
1282 BadTimestamp { index: usize },
1284 ScopeWidened { parent: usize },
1286 ExpiryWidened { parent: usize },
1288 DepthNotIncremented { parent: usize },
1290 DepthExceedsMax { parent: usize },
1292 AudienceChanged { parent: usize },
1294}
1295
1296impl std::fmt::Display for GrantChainError {
1297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1301 match self {
1302 Self::Empty => write!(f, "the chain is empty"),
1303 Self::BadTimestamp { index } => {
1304 write!(
1305 f,
1306 "grant at hop {index} has an unparseable issued_at/expiry"
1307 )
1308 }
1309 Self::ScopeWidened { parent } => {
1310 write!(f, "scope widens at hop {}->{}", parent, parent + 1)
1311 }
1312 Self::ExpiryWidened { parent } => {
1313 write!(
1314 f,
1315 "expiry extends past the parent at hop {}->{}",
1316 parent,
1317 parent + 1
1318 )
1319 }
1320 Self::DepthNotIncremented { parent } => {
1321 write!(
1322 f,
1323 "delegation depth does not increment by one at hop {}->{}",
1324 parent,
1325 parent + 1
1326 )
1327 }
1328 Self::DepthExceedsMax { parent } => {
1329 write!(
1330 f,
1331 "delegation depth exceeds the parent's max_delegation at hop {}->{}",
1332 parent,
1333 parent + 1
1334 )
1335 }
1336 Self::AudienceChanged { parent } => {
1337 write!(f, "audience changes at hop {}->{}", parent, parent + 1)
1338 }
1339 }
1340 }
1341}
1342
1343impl std::error::Error for GrantChainError {}
1344
1345pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
1355 if chain.is_empty() {
1356 return Err(GrantChainError::Empty);
1357 }
1358
1359 for (i, g) in chain.iter().enumerate() {
1362 if parse_rfc3339_to_unix(&g.issued_at).is_none()
1363 || parse_rfc3339_to_unix(&g.expiry).is_none()
1364 {
1365 return Err(GrantChainError::BadTimestamp { index: i });
1366 }
1367 }
1368
1369 for (i, pair) in chain.windows(2).enumerate() {
1370 let parent = &pair[0];
1371 let child = &pair[1];
1372
1373 if !scope_subset(&child.scope, &parent.scope) {
1374 return Err(GrantChainError::ScopeWidened { parent: i });
1375 }
1376
1377 let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
1379 let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
1380 if child_expiry > parent_expiry {
1381 return Err(GrantChainError::ExpiryWidened { parent: i });
1382 }
1383
1384 if child.delegation_depth != parent.delegation_depth + 1 {
1385 return Err(GrantChainError::DepthNotIncremented { parent: i });
1386 }
1387 if child.delegation_depth > parent.max_delegation {
1388 return Err(GrantChainError::DepthExceedsMax { parent: i });
1389 }
1390
1391 if child.audience != parent.audience {
1392 return Err(GrantChainError::AudienceChanged { parent: i });
1393 }
1394 }
1395
1396 Ok(())
1397}
1398
1399fn scope_subset(child: &[String], parent: &[String]) -> bool {
1403 child
1404 .iter()
1405 .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
1406}
1407
1408fn scope_entry_covers(parent: &str, child: &str) -> bool {
1409 if parent == child {
1410 return true;
1411 }
1412 if let Some(parent_prefix) = parent.strip_suffix(".*") {
1413 let child_core = child.strip_suffix(".*").unwrap_or(child);
1416 child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
1417 } else {
1418 false
1419 }
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use super::*;
1425 use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
1426
1427 #[test]
1428 fn effect_confidence_ceiling_gates_on_independent_evidence() {
1429 let with_evidence = Effect {
1431 readback: Some("sha256:observed".into()),
1432 effect_confidence: Some(EffectConfidence::Verified),
1433 ..Default::default()
1434 };
1435 assert!(with_evidence.has_independent_evidence());
1436 assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
1437
1438 let claim_only = Effect {
1441 output_hash: Some("sha256:out".into()),
1442 effect_confidence: Some(EffectConfidence::Verified),
1443 ..Default::default()
1444 };
1445 assert!(!claim_only.has_independent_evidence());
1446 assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
1447
1448 let honest_downgrade = Effect {
1450 effect_confidence: Some(EffectConfidence::Unknown),
1451 ..Default::default()
1452 };
1453 assert_eq!(
1454 honest_downgrade.evidence_ceiling(),
1455 EffectConfidence::NotVerified
1456 );
1457 }
1458
1459 #[test]
1460 fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
1461 let e = Effect {
1462 effect_confidence: Some(EffectConfidence::NotVerified),
1463 ..Default::default()
1464 };
1465 let j = serde_json::to_string(&e).unwrap();
1466 assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
1467
1468 let empty = Effect::default();
1470 assert!(!serde_json::to_string(&empty)
1471 .unwrap()
1472 .contains("effect_confidence"));
1473 }
1474
1475 struct TrustingWitnessAuthority;
1479 impl WitnessAuthority for TrustingWitnessAuthority {
1480 fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
1481 w.is_signed()
1482 && w.observer != actor
1483 && effect.readback.as_deref() == Some(w.observation.as_str())
1484 }
1485 }
1486
1487 #[test]
1488 fn verify_effect_downgrades_unbacked_verified_claim() {
1489 let mut s = good_stmt();
1491 s.actor = "agent://worker".into();
1492 s.effect = Some(Effect {
1493 output_hash: Some("sha256:out".into()),
1494 effect_confidence: Some(EffectConfidence::Verified),
1495 ..Default::default()
1496 });
1497 let v = verify_effect(&s, &NoWitnessAuthority);
1498 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1499 assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1500 assert!(!v.is_verified());
1501 assert!(
1502 v.notes.iter().any(|n| n.contains("downgraded")),
1503 "{:?}",
1504 v.notes
1505 );
1506 }
1507
1508 #[test]
1511 fn finality_and_confidence_are_independent_axes() {
1512 let mut s = good_stmt();
1516 s.effect = Some(Effect {
1517 output_hash: Some("sha256:out".into()),
1518 effect_confidence: Some(EffectConfidence::Partial),
1519 finality: Some(EffectFinality::Finalized),
1520 ..Default::default()
1521 });
1522 let v = verify_effect(&s, &NoWitnessAuthority);
1523 assert_eq!(v.effective_confidence, EffectConfidence::Partial);
1525 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1527 assert_eq!(v.claimed_finality, Some(EffectFinality::Finalized));
1528 }
1529
1530 #[test]
1531 fn unbacked_finalized_is_downgraded_to_indeterminate() {
1532 let mut s = good_stmt();
1537 s.effect = Some(Effect {
1538 output_hash: Some("sha256:out".into()),
1539 finality: Some(EffectFinality::Finalized),
1540 ..Default::default()
1541 });
1542 let v = verify_effect(&s, &NoWitnessAuthority);
1543 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1544 assert!(
1545 v.notes.iter().any(|n| n.contains("Finalized")),
1546 "the downgrade must be stated, not silent: {:?}",
1547 v.notes
1548 );
1549 }
1550
1551 #[test]
1552 fn finalized_backed_by_readback_survives() {
1553 let mut s = good_stmt();
1554 s.effect = Some(Effect {
1555 readback: Some("sha256:observed".into()),
1556 finality: Some(EffectFinality::Finalized),
1557 ..Default::default()
1558 });
1559 let v = verify_effect(&s, &NoWitnessAuthority);
1560 assert_eq!(v.effective_finality, Some(EffectFinality::Finalized));
1561 }
1562
1563 #[test]
1564 fn lesser_finality_claims_pass_through_unchanged() {
1565 for stage in [
1568 EffectFinality::NotAttempted,
1569 EffectFinality::Initiated,
1570 EffectFinality::Failed,
1571 EffectFinality::Indeterminate,
1572 ] {
1573 let mut s = good_stmt();
1574 s.effect = Some(Effect {
1575 finality: Some(stage),
1576 ..Default::default()
1577 });
1578 let v = verify_effect(&s, &NoWitnessAuthority);
1579 assert_eq!(v.effective_finality, Some(stage), "{stage:?} was altered");
1580 }
1581 }
1582
1583 #[test]
1584 fn not_attempted_is_the_no_authority_moved_receipt() {
1585 let e = Effect {
1589 input_hash: Some("sha256:req".into()),
1590 finality: Some(EffectFinality::NotAttempted),
1591 ..Default::default()
1592 };
1593 assert!(EffectFinality::NotAttempted.is_resolved());
1594 assert_eq!(
1595 check_resolution(&e, 4_000_000_000),
1596 ResolutionStatus::Resolved
1597 );
1598 }
1599
1600 fn open_effect(resolution: Option<Resolution>) -> Effect {
1603 Effect {
1604 finality: Some(EffectFinality::Initiated),
1605 resolution,
1606 ..Default::default()
1607 }
1608 }
1609
1610 #[test]
1611 fn unresolved_without_a_deadline_reports_indefinite() {
1612 assert_eq!(
1615 check_resolution(&open_effect(None), 1_800_000_000),
1616 ResolutionStatus::Indefinite
1617 );
1618 }
1619
1620 const DEADLINE: &str = "2026-07-20T11:00:00Z";
1624 fn deadline_unix() -> i64 {
1625 parse_rfc3339_to_unix(DEADLINE).expect("fixture deadline parses") as i64
1626 }
1627
1628 #[test]
1629 fn unresolved_past_its_deadline_reports_the_declared_event() {
1630 let e = open_effect(Some(Resolution {
1631 deadline: DEADLINE.into(),
1632 on_deadline: DeadlineEvent::Escalate,
1633 }));
1634 match check_resolution(&e, deadline_unix() + 90) {
1635 ResolutionStatus::Breached {
1636 on_deadline,
1637 seconds_overdue,
1638 } => {
1639 assert_eq!(on_deadline, DeadlineEvent::Escalate);
1640 assert_eq!(seconds_overdue, 90);
1641 }
1642 other => panic!("expected Breached, got {other:?}"),
1643 }
1644 }
1645
1646 #[test]
1647 fn unresolved_inside_its_window_is_pending() {
1648 let e = open_effect(Some(Resolution {
1649 deadline: DEADLINE.into(),
1650 on_deadline: DeadlineEvent::Timeout,
1651 }));
1652 match check_resolution(&e, deadline_unix() - 60) {
1653 ResolutionStatus::Pending { seconds_remaining } => {
1654 assert_eq!(seconds_remaining, 60)
1655 }
1656 other => panic!("expected Pending, got {other:?}"),
1657 }
1658 }
1659
1660 #[test]
1661 fn a_resolved_effect_cannot_breach() {
1662 let e = Effect {
1664 finality: Some(EffectFinality::Finalized),
1665 resolution: Some(Resolution {
1666 deadline: "2026-07-20T11:00:00Z".into(),
1667 on_deadline: DeadlineEvent::Tombstone,
1668 }),
1669 ..Default::default()
1670 };
1671 assert_eq!(
1672 check_resolution(&e, 4_000_000_000),
1673 ResolutionStatus::Resolved
1674 );
1675 }
1676
1677 #[test]
1678 fn unparseable_deadline_fails_toward_unknown() {
1679 let e = open_effect(Some(Resolution {
1682 deadline: "whenever".into(),
1683 on_deadline: DeadlineEvent::Timeout,
1684 }));
1685 assert_eq!(
1686 check_resolution(&e, 1_800_000_000),
1687 ResolutionStatus::BadDeadline
1688 );
1689 }
1690
1691 #[test]
1692 fn missing_finality_is_treated_as_unresolved() {
1693 let e = Effect {
1696 output_hash: Some("sha256:out".into()),
1697 ..Default::default()
1698 };
1699 assert_eq!(
1700 check_resolution(&e, 1_800_000_000),
1701 ResolutionStatus::Indefinite
1702 );
1703 }
1704
1705 #[test]
1706 fn finality_and_resolution_are_omitted_when_absent() {
1707 let json = serde_json::to_string(&Effect {
1709 output_hash: Some("sha256:out".into()),
1710 ..Default::default()
1711 })
1712 .unwrap();
1713 assert!(!json.contains("finality"), "{json}");
1714 assert!(!json.contains("resolution"), "{json}");
1715 }
1716
1717 #[test]
1718 fn verify_effect_honors_verified_backed_by_readback() {
1719 let mut s = good_stmt();
1720 s.effect = Some(Effect {
1721 readback: Some("sha256:observed".into()),
1722 effect_confidence: Some(EffectConfidence::Verified),
1723 ..Default::default()
1724 });
1725 let v = verify_effect(&s, &NoWitnessAuthority);
1726 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1727 assert!(v.is_verified());
1728 }
1729
1730 #[test]
1731 fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1732 let mut s = good_stmt();
1735 s.actor = "agent://worker".into();
1736 s.effect = Some(Effect {
1737 readback: Some("sha256:state".into()),
1738 effect_confidence: Some(EffectConfidence::Verified),
1739 witnesses: vec![Witness {
1740 observer: "agent://auditor".into(),
1741 observation: "sha256:state".into(),
1742 observed_at: Some("2026-07-20T10:00:00Z".into()),
1743 signature: Some("ed25519:sig".into()),
1744 }],
1745 ..Default::default()
1746 });
1747 let v = verify_effect(&s, &TrustingWitnessAuthority);
1748 assert_eq!(v.trusted_witnesses, 1);
1749 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1750
1751 let mut self_witness = s.clone();
1753 if let Some(e) = self_witness.effect.as_mut() {
1754 e.readback = None; e.witnesses[0].observer = "agent://worker".into();
1756 }
1757 let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1758 assert_eq!(v2.trusted_witnesses, 0);
1759 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1760 assert!(v2
1761 .notes
1762 .iter()
1763 .any(|n| n.contains("not independently trusted")));
1764 }
1765
1766 #[test]
1767 fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1768 for c in [
1771 EffectConfidence::Partial,
1772 EffectConfidence::Ambiguous,
1773 EffectConfidence::Unknown,
1774 EffectConfidence::NotVerified,
1775 ] {
1776 let mut s = good_stmt();
1777 s.effect = Some(Effect {
1778 effect_confidence: Some(c),
1779 ..Default::default()
1780 });
1781 let v = verify_effect(&s, &NoWitnessAuthority);
1782 assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1783 }
1784 }
1785
1786 #[test]
1787 fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1788 let s = good_stmt();
1790 assert!(s.effect.is_none());
1791 let v = verify_effect(&s, &NoWitnessAuthority);
1792 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1793 assert_eq!(v.claimed_confidence, None);
1794 assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1795
1796 let mut s2 = good_stmt();
1798 s2.effect = Some(Effect {
1799 output_hash: Some("sha256:out".into()),
1800 ..Default::default()
1801 });
1802 let v2 = verify_effect(&s2, &NoWitnessAuthority);
1803 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1804 assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1805 }
1806
1807 #[test]
1808 fn witness_does_not_inflate_evidence_ceiling() {
1809 let signed_witness = Witness {
1814 observer: "agent://auditor".into(),
1815 observation: "sha256:observed".into(),
1816 observed_at: Some("2026-07-20T10:00:00Z".into()),
1817 signature: Some("ed25519:sig".into()),
1818 };
1819 let e = Effect {
1820 witnesses: vec![signed_witness.clone()],
1821 effect_confidence: Some(EffectConfidence::Verified),
1822 ..Default::default()
1823 };
1824 assert!(!e.has_independent_evidence());
1825 assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1826 assert!(signed_witness.is_signed());
1829 assert_eq!(e.signed_witnesses().count(), 1);
1830
1831 let unsigned = Effect {
1833 witnesses: vec![Witness {
1834 observer: "agent://auditor".into(),
1835 observation: "sha256:observed".into(),
1836 ..Default::default()
1837 }],
1838 ..Default::default()
1839 };
1840 assert_eq!(unsigned.signed_witnesses().count(), 0);
1841 }
1842
1843 #[test]
1844 fn witnesses_serialize_and_omit_when_empty() {
1845 let empty = Effect::default();
1846 assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1847
1848 let e = Effect {
1849 witnesses: vec![Witness {
1850 observer: "key_9f2c".into(),
1851 observation: "sha256:obs".into(),
1852 observed_at: None,
1853 signature: Some("ed25519:sig".into()),
1854 }],
1855 ..Default::default()
1856 };
1857 let j = serde_json::to_string(&e).unwrap();
1858 assert!(j.contains("\"witnesses\":[{"), "{j}");
1859 assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1860 assert!(!j.contains("observed_at"), "{j}");
1862 let back: Effect = serde_json::from_str(&j).unwrap();
1863 assert_eq!(back.witnesses.len(), 1);
1864 assert!(back.witnesses[0].is_signed());
1865 }
1866
1867 #[test]
1868 fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1869 assert!(RuntimeIdentity::default().is_unbound());
1870
1871 let with_model = RuntimeIdentity {
1873 model: Some("claude-opus-4-8".into()),
1874 ..Default::default()
1875 };
1876 assert!(!with_model.is_unbound());
1877
1878 let with_prompt = RuntimeIdentity {
1879 system_prompt_hash: Some("sha256:sys".into()),
1880 ..Default::default()
1881 };
1882 assert!(!with_prompt.is_unbound());
1883 }
1884
1885 #[test]
1886 fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1887 let rt = RuntimeIdentity {
1888 provider: Some("anthropic".into()),
1889 model: Some("claude-opus-4-8".into()),
1890 tool_schema_hash: Some("sha256:tools".into()),
1891 system_prompt_hash: None,
1892 };
1893 let j = serde_json::to_string(&rt).unwrap();
1894 assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1895 assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1896 assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1897 assert!(!j.contains("system_prompt_hash"), "{j}");
1899
1900 let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
1902 assert_eq!(empty, "{}");
1903 let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
1904 assert!(back.is_unbound());
1905 }
1906
1907 #[test]
1908 fn runtime_is_omitted_from_statement_when_absent() {
1909 let s = good_stmt();
1912 assert!(s.runtime.is_none());
1913 let j = serde_json::to_string(&s).unwrap();
1914 assert!(!j.contains("runtime"), "{j}");
1915
1916 let mut with_rt = good_stmt();
1918 with_rt.runtime = Some(RuntimeIdentity {
1919 model: Some("claude-opus-4-8".into()),
1920 ..Default::default()
1921 });
1922 let j2 = serde_json::to_string(&with_rt).unwrap();
1923 assert!(j2.contains("\"runtime\""), "{j2}");
1924 let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
1925 assert_eq!(
1926 back.runtime.unwrap().model.as_deref(),
1927 Some("claude-opus-4-8")
1928 );
1929 }
1930
1931 fn base_mandate() -> Mandate {
1932 Mandate {
1933 grant_id: "grant_9c2f".into(),
1934 grantor: "key_parent".into(),
1935 grantee: Some("key_holder".into()),
1939 issuer_sig: None,
1940 objective_hash: Some("sha256:abc".into()),
1941 scope: vec!["payments.charge".into()],
1942 audience: "acme-payments-api".into(),
1943 parent_request_id: Some("req_7d3e".into()),
1944 delegation_depth: 2,
1945 issued_at: "2026-07-11T19:50:00Z".into(),
1946 expiry: "2026-07-11T20:50:00Z".into(),
1947 max_delegation: 3,
1948 revocation: Revocation {
1949 path: "hub://acme/revocations".into(),
1950 revoked_at: None,
1951 },
1952 chain: Vec::new(),
1953 }
1954 }
1955
1956 fn good_stmt() -> ActionStatementV2 {
1959 let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
1960 s.timestamp = "2026-07-11T19:53:09Z".into();
1961 s.audience = Some("acme-payments-api".into());
1962 s
1963 }
1964
1965 struct StaticRevocation(RevocationStatus);
1966 impl RevocationSource for StaticRevocation {
1967 fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
1968 self.0.clone()
1969 }
1970 }
1971
1972 #[test]
1975 fn scope_exact_and_glob() {
1976 assert!(action_in_scope(
1977 "payments.charge",
1978 &["payments.charge".into()]
1979 ));
1980 assert!(action_in_scope("payments.charge", &["payments.*".into()]));
1981 assert!(action_in_scope("payments", &["payments.*".into()]));
1982 assert!(!action_in_scope(
1983 "payments.refund",
1984 &["payments.charge".into()]
1985 ));
1986 assert!(!action_in_scope("email.send", &["payments.*".into()]));
1987 assert!(!action_in_scope("anything", &["*".into()]));
1989 assert!(action_in_scope("*", &["*".into()]));
1990 }
1991
1992 #[test]
1993 fn empty_scope_authorizes_nothing() {
1994 let mut s = good_stmt();
1995 s.mandate.scope = vec![];
1996 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1997 MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
1998 v => panic!("empty scope must fail, got {v:?}"),
1999 }
2000 }
2001
2002 #[test]
2003 fn action_out_of_scope_fails() {
2004 let mut s = good_stmt();
2005 s.action = "payments.refund".into();
2006 assert!(matches!(
2007 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2008 MandateVerdict::Fail(_)
2009 ));
2010 }
2011
2012 #[test]
2015 fn audience_match_passes_layer() {
2016 let s = good_stmt();
2017 assert_eq!(
2018 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2019 MandateVerdict::Pass
2020 );
2021 }
2022
2023 #[test]
2024 fn audience_mismatch_fails() {
2025 let mut s = good_stmt();
2026 s.audience = Some("evil-api".into());
2027 assert!(matches!(
2028 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2029 MandateVerdict::Fail(_)
2030 ));
2031 }
2032
2033 #[test]
2034 fn missing_action_audience_is_unverified_not_pass() {
2035 let mut s = good_stmt();
2036 s.audience = None;
2037 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2038 MandateVerdict::Unverified(rs) => {
2039 assert!(rs.iter().any(|r| r.contains("recorded no audience")))
2040 }
2041 v => panic!("missing audience must be Unverified, got {v:?}"),
2042 }
2043 }
2044
2045 #[test]
2046 fn empty_mandate_audience_fails() {
2047 let mut s = good_stmt();
2048 s.mandate.audience = "".into();
2049 assert!(matches!(
2050 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2051 MandateVerdict::Fail(_)
2052 ));
2053 }
2054
2055 #[test]
2058 fn signed_before_issued_fails() {
2059 let mut s = good_stmt();
2060 s.timestamp = "2026-07-11T19:49:59Z".into(); assert!(matches!(
2062 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2063 MandateVerdict::Fail(_)
2064 ));
2065 }
2066
2067 #[test]
2068 fn signed_at_expiry_fails() {
2069 let mut s = good_stmt();
2070 s.timestamp = "2026-07-11T20:50:00Z".into(); assert!(matches!(
2072 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2073 MandateVerdict::Fail(_)
2074 ));
2075 }
2076
2077 #[test]
2078 fn signed_within_window_passes() {
2079 let s = good_stmt(); assert_eq!(
2081 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2082 MandateVerdict::Pass
2083 );
2084 }
2085
2086 #[test]
2087 fn malformed_timestamp_fails_closed() {
2088 let mut s = good_stmt();
2089 s.timestamp = "not-a-timestamp".into();
2090 assert!(matches!(
2091 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2092 MandateVerdict::Fail(_)
2093 ));
2094 }
2095
2096 #[test]
2099 fn revoked_after_signing_still_passes() {
2100 let s = good_stmt();
2103 let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
2104 assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
2105 }
2106
2107 #[test]
2108 fn revoked_before_signing_fails() {
2109 let s = good_stmt(); let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
2111 assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
2112 }
2113
2114 #[test]
2115 fn revocation_unknown_is_unverified() {
2116 let s = good_stmt();
2117 match verify_mandate(&s, &NoRevocationSource) {
2118 MandateVerdict::Unverified(rs) => {
2119 assert!(rs
2120 .iter()
2121 .any(|r| r.contains("revocation could not be checked")))
2122 }
2123 v => panic!("no revocation source must be Unverified, got {v:?}"),
2124 }
2125 }
2126
2127 #[test]
2128 fn fail_takes_precedence_over_unverified() {
2129 let mut s = good_stmt();
2132 s.action = "payments.refund".into();
2133 assert!(matches!(
2134 verify_mandate(&s, &NoRevocationSource),
2135 MandateVerdict::Fail(_)
2136 ));
2137 }
2138
2139 #[test]
2140 fn wrong_type_fails() {
2141 let mut s = good_stmt();
2142 s.type_ = "treeship/action/v1".into();
2143 assert!(matches!(
2144 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2145 MandateVerdict::Fail(_)
2146 ));
2147 }
2148
2149 #[test]
2152 fn mandate_is_bound_into_signature() {
2153 let signer = Ed25519Signer::generate("key_test").unwrap();
2154 let pt = payload_type_v2("action");
2155
2156 let a = good_stmt();
2157 let mut b = good_stmt();
2158 b.mandate.scope = vec!["payments.*".into()]; let ra = sign(&pt, &a, &signer).unwrap();
2161 let rb = sign(&pt, &b, &signer).unwrap();
2162 assert_ne!(
2163 ra.artifact_id, rb.artifact_id,
2164 "changing mandate.scope must change the signed artifact id"
2165 );
2166 }
2167
2168 #[test]
2169 fn v2_sign_verify_roundtrip() {
2170 let signer = Ed25519Signer::generate("key_test").unwrap();
2171 let verifier = EnvVerifier::from_signer(&signer);
2172 let pt = payload_type_v2("action");
2173
2174 let mut s = good_stmt();
2175 s.effect = Some(Effect {
2176 output_hash: Some("sha256:out".into()),
2177 readback: Some("sha256:observed".into()),
2178 bytes_moved: Some(1_048_576),
2179 cost: Some(Cost {
2180 unit: "usd_micros".into(),
2181 amount: 4200,
2182 }),
2183 side_effects: vec!["db:users.update".into()],
2184 ..Default::default()
2185 });
2186
2187 let signed = sign(&pt, &s, &signer).unwrap();
2188 verifier.verify(&signed.envelope).unwrap();
2189
2190 let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
2191 assert_eq!(decoded.type_, TYPE_ACTION_V2);
2192 assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
2193 assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
2194 }
2195
2196 #[test]
2197 fn v2_payload_type_differs_from_v1() {
2198 assert_eq!(
2199 payload_type_v2("action"),
2200 "application/vnd.treeship.action.v2+json"
2201 );
2202 assert_ne!(
2203 payload_type_v2("action"),
2204 super::super::payload_type("action")
2205 );
2206 }
2207
2208 #[test]
2211 fn a_bearer_mandate_is_reported_not_passed() {
2212 let mut s = good_stmt();
2216 s.mandate.grantee = None;
2217 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2218 MandateVerdict::Unverified(r) => assert!(
2219 r.iter().any(|x| x.contains("bearer")),
2220 "the reason must name it: {r:?}"
2221 ),
2222 other => panic!("bearer must not pass silently, got {other:?}"),
2223 }
2224 }
2225
2226 #[test]
2227 fn a_bound_mandate_clears_the_holder_layer() {
2228 let s = good_stmt();
2229 assert_eq!(
2230 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2231 MandateVerdict::Pass
2232 );
2233 }
2234
2235 #[test]
2236 fn exercisable_by_is_exact_and_bearer_admits_everyone() {
2237 let mut g = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2238 g.grantee = Some("holder-key".into());
2239 assert!(g.binds_holder());
2240 assert!(g.exercisable_by("holder-key"));
2241 assert!(!g.exercisable_by("someone-else"));
2242
2243 g.grantee = None;
2246 assert!(!g.binds_holder());
2247 assert!(g.exercisable_by("anyone-at-all"));
2248 }
2249
2250 #[test]
2251 fn grantee_is_covered_by_the_signed_bytes() {
2252 let mut a = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2255 let mut b = a.clone();
2256 a.grantee = Some("alice".into());
2257 b.grantee = Some("bob".into());
2258 assert_ne!(a.canonical_for_signing(), b.canonical_for_signing());
2259 assert_ne!(a.derive_grant_id(), b.derive_grant_id());
2260 }
2261
2262 fn grant(
2265 id: &str,
2266 grantor: &str,
2267 scope: &[&str],
2268 depth: u32,
2269 expiry: &str,
2270 max_deleg: u32,
2271 ) -> Grant {
2272 Grant {
2273 grant_id: id.into(),
2274 grantor: grantor.into(),
2275 grantee: None,
2276 issuer_sig: None,
2277 scope: scope.iter().map(|s| (*s).into()).collect(),
2278 audience: "acme-payments-api".into(),
2279 parent_request_id: None,
2280 parent_grant_id: None,
2281 delegation_depth: depth,
2282 issued_at: "2026-07-11T19:00:00Z".into(),
2283 expiry: expiry.into(),
2284 max_delegation: max_deleg,
2285 objective_hash: None,
2286 }
2287 }
2288
2289 #[test]
2290 fn grant_sign_verify_roundtrip_and_tamper() {
2291 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2292 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2293 let mut g = grant(
2294 "grant_root",
2295 &grantor,
2296 &["payments.*"],
2297 0,
2298 "2026-07-11T21:00:00Z",
2299 3,
2300 );
2301 g.grant_id = g.derive_grant_id();
2304
2305 let sig = g.sign_canonical(&signer).unwrap();
2306 assert!(g.verify_canonical(&sig));
2307
2308 g.scope.push("email.*".into());
2310 assert!(!g.verify_canonical(&sig));
2311 }
2312
2313 #[test]
2314 fn grant_verify_rejects_wrong_key() {
2315 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2316 let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
2317 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2318 let g = grant(
2319 "grant_root",
2320 &grantor,
2321 &["payments.*"],
2322 0,
2323 "2026-07-11T21:00:00Z",
2324 3,
2325 );
2326 let sig = g.sign_canonical(&attacker).unwrap();
2327 assert!(!g.verify_canonical(&sig));
2328 }
2329
2330 #[test]
2331 fn valid_attenuating_chain_ok() {
2332 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2333 let child = grant(
2334 "g1",
2335 "k",
2336 &["payments.charge"],
2337 1,
2338 "2026-07-11T20:30:00Z",
2339 3,
2340 );
2341 assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
2342 }
2343
2344 #[test]
2345 fn scope_widening_rejected() {
2346 let root = grant(
2347 "g0",
2348 "k",
2349 &["payments.charge"],
2350 0,
2351 "2026-07-11T21:00:00Z",
2352 3,
2353 );
2354 let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
2355 assert_eq!(
2356 verify_grant_chain(&[root, child]),
2357 Err(GrantChainError::ScopeWidened { parent: 0 })
2358 );
2359 }
2360
2361 #[test]
2362 fn expiry_widening_rejected() {
2363 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2364 let child = grant(
2365 "g1",
2366 "k",
2367 &["payments.charge"],
2368 1,
2369 "2026-07-11T22:00:00Z",
2370 3,
2371 );
2372 assert_eq!(
2373 verify_grant_chain(&[root, child]),
2374 Err(GrantChainError::ExpiryWidened { parent: 0 })
2375 );
2376 }
2377
2378 #[test]
2379 fn depth_not_incremented_rejected() {
2380 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2381 let child = grant(
2382 "g1",
2383 "k",
2384 &["payments.charge"],
2385 2,
2386 "2026-07-11T21:00:00Z",
2387 3,
2388 );
2389 assert_eq!(
2390 verify_grant_chain(&[root, child]),
2391 Err(GrantChainError::DepthNotIncremented { parent: 0 })
2392 );
2393 }
2394
2395 #[test]
2396 fn depth_exceeds_max_rejected() {
2397 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
2398 let child = grant(
2399 "g1",
2400 "k",
2401 &["payments.charge"],
2402 1,
2403 "2026-07-11T21:00:00Z",
2404 0,
2405 );
2406 assert_eq!(
2407 verify_grant_chain(&[root, child]),
2408 Err(GrantChainError::DepthExceedsMax { parent: 0 })
2409 );
2410 }
2411
2412 #[test]
2413 fn audience_change_rejected() {
2414 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2415 let mut child = grant(
2416 "g1",
2417 "k",
2418 &["payments.charge"],
2419 1,
2420 "2026-07-11T21:00:00Z",
2421 3,
2422 );
2423 child.audience = "other-api".into();
2424 assert_eq!(
2425 verify_grant_chain(&[root, child]),
2426 Err(GrantChainError::AudienceChanged { parent: 0 })
2427 );
2428 }
2429
2430 #[test]
2431 fn empty_chain_rejected() {
2432 assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
2433 }
2434
2435 #[test]
2436 fn bad_timestamp_in_chain_rejected() {
2437 let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2438 root.expiry = "nope".into();
2439 assert_eq!(
2440 verify_grant_chain(&[root]),
2441 Err(GrantChainError::BadTimestamp { index: 0 })
2442 );
2443 }
2444
2445 #[test]
2446 fn single_grant_chain_ok() {
2447 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2448 assert_eq!(verify_grant_chain(&[root]), Ok(()));
2449 }
2450
2451 fn mk_grant(
2455 signer: &Ed25519Signer,
2456 grantor_pk: &str,
2457 scope: Vec<&str>,
2458 depth: u32,
2459 parent: Option<&str>,
2460 ) -> Grant {
2461 let mut g = Grant {
2462 grant_id: String::new(),
2463 grantor: grantor_pk.to_string(),
2464 grantee: None,
2465 issuer_sig: None,
2466 scope: scope.into_iter().map(String::from).collect(),
2467 audience: "acme".into(),
2468 parent_request_id: None,
2469 parent_grant_id: parent.map(String::from),
2470 delegation_depth: depth,
2471 issued_at: "2026-07-20T10:00:00Z".into(),
2472 expiry: "2026-07-20T11:00:00Z".into(),
2473 max_delegation: 3,
2474 objective_hash: None,
2475 };
2476 g.grant_id = g.derive_grant_id();
2477 g.issuer_sig = Some(g.sign_canonical(signer).unwrap());
2478 g
2479 }
2480
2481 fn chain_fixture() -> (Grant, Grant, Ed25519Signer) {
2482 let signer = Ed25519Signer::generate("issuer").unwrap();
2483 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2484 let root = mk_grant(&signer, &pk, vec!["payments.*"], 0, None);
2485 let leaf = mk_grant(
2486 &signer,
2487 &pk,
2488 vec!["payments.charge"],
2489 1,
2490 Some(&root.grant_id),
2491 );
2492 (root, leaf, signer)
2493 }
2494
2495 fn mandate_with(leaf: &Grant, chain: Vec<Grant>) -> Mandate {
2496 let mut m = base_mandate();
2497 m.grant_id = leaf.grant_id.clone();
2498 m.chain = chain;
2499 m
2500 }
2501
2502 #[test]
2503 fn grant_id_is_content_derived_and_stable() {
2504 let (root, _, _) = chain_fixture();
2505 assert!(root.grant_id.starts_with("grn_"));
2506 assert_eq!(root.grant_id, root.derive_grant_id());
2507 let mut altered = root.clone();
2509 altered.scope = vec!["payments.refund".into()];
2510 assert_ne!(altered.derive_grant_id(), root.grant_id);
2511 }
2512
2513 #[test]
2514 fn hand_chosen_id_fails_verification() {
2515 let (mut root, _, _) = chain_fixture();
2516 let sig = root.issuer_sig.clone().unwrap();
2517 root.grant_id = "grn_deadbeefdeadbeef".into();
2518 assert!(
2519 !root.verify_canonical(&sig),
2520 "an id that was chosen rather than computed must not verify"
2521 );
2522 }
2523
2524 #[test]
2525 fn resolves_root_first_regardless_of_carrier_order() {
2526 let (root, leaf, _) = chain_fixture();
2527 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2529 let resolved = resolve_grant_chain(&m).expect("resolves");
2530 assert_eq!(resolved.len(), 2);
2531 assert_eq!(resolved[0].grant_id, root.grant_id, "root must come first");
2532 assert_eq!(resolved[1].grant_id, leaf.grant_id);
2533 }
2534
2535 #[test]
2536 fn truncated_chain_is_rejected() {
2537 let (_, leaf, _) = chain_fixture();
2538 let m = mandate_with(&leaf, vec![leaf.clone()]);
2541 match resolve_grant_chain(&m) {
2542 Err(ChainResolveError::AncestorMissing { .. }) => {}
2543 other => panic!("truncation must be caught, got {other:?}"),
2544 }
2545 }
2546
2547 #[test]
2548 fn spliced_decoy_grant_is_rejected() {
2549 let (root, leaf, signer) = chain_fixture();
2550 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2551 let decoy = mk_grant(&signer, &pk, vec!["email.send"], 0, None);
2555 assert_ne!(decoy.grant_id, root.grant_id);
2556 let m = mandate_with(&leaf, vec![root.clone(), leaf.clone(), decoy]);
2557 match resolve_grant_chain(&m) {
2558 Err(ChainResolveError::UnreachableExtras { count }) => assert_eq!(count, 1),
2559 other => panic!("unreachable extras must be refused, got {other:?}"),
2560 }
2561 }
2562
2563 #[test]
2564 fn unsigned_ancestor_is_rejected() {
2565 let (mut root, leaf, _) = chain_fixture();
2566 root.issuer_sig = None;
2567 let m = mandate_with(&leaf, vec![root, leaf.clone()]);
2568 assert!(matches!(
2569 resolve_grant_chain(&m),
2570 Err(ChainResolveError::Unsigned { .. })
2571 ));
2572 }
2573
2574 #[test]
2575 fn resolved_chain_feeds_attenuation_check() {
2576 let (root, leaf, _) = chain_fixture();
2579 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2580 let resolved = resolve_grant_chain(&m).expect("resolves");
2581 assert!(
2582 verify_grant_chain(&resolved).is_ok(),
2583 "narrowing scope at depth+1 must satisfy attenuation"
2584 );
2585 }
2586
2587 #[test]
2588 fn leaf_not_in_chain_is_rejected() {
2589 let (root, leaf, _) = chain_fixture();
2593 let mut m = mandate_with(&leaf, vec![root.clone()]);
2594 m.grant_id = leaf.grant_id.clone();
2595 match resolve_grant_chain(&m) {
2596 Err(ChainResolveError::LeafMissing { grant_id }) => {
2597 assert_eq!(grant_id, leaf.grant_id);
2598 }
2599 other => panic!("a mandate naming an absent leaf must fail, got {other:?}"),
2600 }
2601 }
2602
2603 #[test]
2604 fn ancestor_signed_by_a_stranger_is_rejected() {
2605 let (root, leaf, _) = chain_fixture();
2609 let stranger = Ed25519Signer::generate("stranger").unwrap();
2610 let mut forged = root.clone();
2611 forged.issuer_sig = Some(forged.sign_canonical(&stranger).unwrap());
2612 assert_eq!(
2613 forged.grant_id, root.grant_id,
2614 "signing with another key must not change the content id"
2615 );
2616
2617 let m = mandate_with(&leaf, vec![forged, leaf.clone()]);
2618 match resolve_grant_chain(&m) {
2619 Err(ChainResolveError::BadSignature { grant_id }) => {
2620 assert_eq!(grant_id, root.grant_id);
2621 }
2622 other => panic!("a grant signed by a non-grantor must fail, got {other:?}"),
2623 }
2624 }
2625
2626 #[test]
2627 fn inconsistent_id_is_caught_before_signature_check() {
2628 let (root, leaf, _) = chain_fixture();
2632 let mut tampered = root.clone();
2633 tampered.grant_id = "grn_0000000000000000".into();
2634 let m = mandate_with(&leaf, vec![tampered, leaf.clone()]);
2635 assert!(matches!(
2636 resolve_grant_chain(&m),
2637 Err(ChainResolveError::InconsistentId { .. })
2638 ));
2639 }
2640}