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 sha2::{Digest, Sha256};
47use ed25519_dalek::{Signature, VerifyingKey};
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 = "Vec::is_empty")]
147 pub chain: Vec<Grant>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct Cost {
153 pub unit: String,
154 pub amount: u64,
155}
156
157#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub struct Witness {
173 pub observer: String,
177 pub observation: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub observed_at: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub signature: Option<String>,
190}
191
192impl Witness {
193 pub fn is_signed(&self) -> bool {
198 self.signature.is_some()
199 }
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
207pub struct Effect {
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub input_hash: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub output_hash: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub readback: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub bytes_moved: Option<u64>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub cost: Option<Cost>,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub side_effects: Vec<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub context_snapshot: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub effect_confidence: Option<EffectConfidence>,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub witnesses: Vec<Witness>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub finality: Option<EffectFinality>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub resolution: Option<Resolution>,
257}
258
259#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum EffectConfidence {
271 Verified,
274 Partial,
277 Ambiguous,
279 Unknown,
281 NotVerified,
284}
285
286#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum EffectFinality {
299 NotAttempted,
304 Initiated,
307 Finalized,
309 Failed,
311 Indeterminate,
315}
316
317impl EffectFinality {
318 pub fn is_resolved(self) -> bool {
322 matches!(
323 self,
324 Self::NotAttempted | Self::Finalized | Self::Failed
325 )
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct Resolution {
338 pub deadline: String,
340 pub on_deadline: DeadlineEvent,
342}
343
344#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "snake_case")]
347pub enum DeadlineEvent {
348 Timeout,
350 Escalate,
352 Tombstone,
354 Inherit,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq)]
360pub enum ResolutionStatus {
361 Resolved,
363 Indefinite,
367 Pending { seconds_remaining: i64 },
369 Breached {
372 on_deadline: DeadlineEvent,
373 seconds_overdue: i64,
374 },
375 BadDeadline,
378}
379
380pub fn check_resolution(effect: &Effect, now_unix: i64) -> ResolutionStatus {
392 let resolved = effect
393 .finality
394 .map(EffectFinality::is_resolved)
395 .unwrap_or(false);
396 if resolved {
397 return ResolutionStatus::Resolved;
398 }
399
400 let res = match &effect.resolution {
401 Some(r) => r,
402 None => return ResolutionStatus::Indefinite,
403 };
404
405 let deadline = match parse_rfc3339_to_unix(&res.deadline) {
408 Some(t) if t <= i64::MAX as u64 => t as i64,
409 _ => return ResolutionStatus::BadDeadline,
410 };
411
412 if now_unix > deadline {
413 ResolutionStatus::Breached {
414 on_deadline: res.on_deadline,
415 seconds_overdue: now_unix - deadline,
416 }
417 } else {
418 ResolutionStatus::Pending {
419 seconds_remaining: deadline - now_unix,
420 }
421 }
422}
423
424impl Effect {
425 pub fn has_independent_evidence(&self) -> bool {
436 self.readback.is_some()
437 }
438
439 pub fn signed_witnesses(&self) -> impl Iterator<Item = &Witness> {
443 self.witnesses.iter().filter(|w| w.is_signed())
444 }
445
446 pub fn evidence_ceiling(&self) -> EffectConfidence {
452 if self.has_independent_evidence() {
453 EffectConfidence::Verified
454 } else {
455 EffectConfidence::NotVerified
456 }
457 }
458}
459
460#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
475pub struct RuntimeIdentity {
476 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub provider: Option<String>,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub model: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub tool_schema_hash: Option<String>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub system_prompt_hash: Option<String>,
488}
489
490impl RuntimeIdentity {
491 pub fn is_unbound(&self) -> bool {
496 self.provider.is_none()
497 && self.model.is_none()
498 && self.tool_schema_hash.is_none()
499 && self.system_prompt_hash.is_none()
500 }
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct ActionStatementV2 {
509 #[serde(rename = "type")]
510 pub type_: String,
511
512 pub timestamp: String,
515
516 pub actor: String,
517 pub action: String,
518
519 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub audience: Option<String>,
525
526 #[serde(default, skip_serializing_if = "subject_is_empty")]
527 pub subject: SubjectRef,
528
529 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
530 pub parent_id: Option<String>,
531
532 pub mandate: Mandate,
533
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub effect: Option<Effect>,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub runtime: Option<RuntimeIdentity>,
542
543 #[serde(skip_serializing_if = "Option::is_none")]
544 pub meta: Option<serde_json::Value>,
545}
546
547fn subject_is_empty(s: &SubjectRef) -> bool {
548 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
549}
550
551impl ActionStatementV2 {
552 pub fn new(actor: impl Into<String>, action: impl Into<String>, mandate: Mandate) -> Self {
554 Self {
555 type_: TYPE_ACTION_V2.into(),
556 timestamp: super::unix_to_rfc3339(now_unix()),
557 actor: actor.into(),
558 action: action.into(),
559 audience: None,
560 subject: SubjectRef::default(),
561 parent_id: None,
562 mandate,
563 effect: None,
564 runtime: None,
565 meta: None,
566 }
567 }
568}
569
570fn now_unix() -> u64 {
571 use std::time::{SystemTime, UNIX_EPOCH};
572 SystemTime::now()
573 .duration_since(UNIX_EPOCH)
574 .unwrap_or_default()
575 .as_secs()
576}
577
578pub fn action_in_scope(action: &str, scope: &[String]) -> bool {
588 scope.iter().any(|entry| scope_entry_matches(entry, action))
589}
590
591fn scope_entry_matches(entry: &str, action: &str) -> bool {
592 if let Some(prefix) = entry.strip_suffix(".*") {
593 action == prefix || action.starts_with(&format!("{prefix}."))
594 } else {
595 entry == action
596 }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
605pub enum RevocationStatus {
606 NotRevoked,
608 RevokedAt(String),
610 Unknown(String),
613}
614
615pub trait RevocationSource {
621 fn status(&self, grant_id: &str, path: &str) -> RevocationStatus;
622}
623
624pub struct NoRevocationSource;
626
627impl RevocationSource for NoRevocationSource {
628 fn status(&self, _grant_id: &str, path: &str) -> RevocationStatus {
629 RevocationStatus::Unknown(format!("no revocation source configured for path '{path}'"))
630 }
631}
632
633#[derive(Debug, Clone, PartialEq, Eq)]
643pub enum MandateVerdict {
644 Pass,
645 Unverified(Vec<String>),
646 Fail(Vec<String>),
647}
648
649impl MandateVerdict {
650 pub fn is_pass(&self) -> bool {
651 matches!(self, MandateVerdict::Pass)
652 }
653}
654
655pub fn verify_mandate(
666 stmt: &ActionStatementV2,
667 revocation: &dyn RevocationSource,
668) -> MandateVerdict {
669 let mut fail: Vec<String> = Vec::new();
670 let mut unver: Vec<String> = Vec::new();
671
672 if stmt.type_ != TYPE_ACTION_V2 {
673 return MandateVerdict::Fail(vec![format!(
674 "statement type '{}' is not {TYPE_ACTION_V2}",
675 stmt.type_
676 )]);
677 }
678
679 let m = &stmt.mandate;
680
681 let signed_at = match parse_rfc3339_to_unix(&stmt.timestamp) {
684 Some(t) => t,
685 None => {
686 return MandateVerdict::Fail(vec![format!(
687 "timestamp '{}' is not RFC 3339",
688 stmt.timestamp
689 )])
690 }
691 };
692
693 if m.scope.is_empty() {
695 fail.push("mandate.scope is empty: it authorizes no action".into());
696 } else if !action_in_scope(&stmt.action, &m.scope) {
697 fail.push(format!(
698 "action '{}' is not in mandate scope {:?}",
699 stmt.action, m.scope
700 ));
701 }
702
703 if m.audience.trim().is_empty() {
705 fail.push("mandate.audience is empty: the grant is not bound to an audience".into());
706 } else {
707 match &stmt.audience {
708 Some(a) if a == &m.audience => {}
709 Some(a) => fail.push(format!(
710 "action audience '{a}' does not match mandate audience '{}'",
711 m.audience
712 )),
713 None => unver
714 .push("action recorded no audience; cannot confirm it matched the mandate".into()),
715 }
716 }
717
718 match (
720 parse_rfc3339_to_unix(&m.issued_at),
721 parse_rfc3339_to_unix(&m.expiry),
722 ) {
723 (Some(issued), Some(expiry)) => {
724 if expiry <= issued {
725 fail.push(format!(
726 "mandate expiry '{}' is not after issued_at '{}'",
727 m.expiry, m.issued_at
728 ));
729 }
730 if signed_at < issued {
731 fail.push(format!(
732 "signed_at '{}' is before mandate issued_at '{}'",
733 stmt.timestamp, m.issued_at
734 ));
735 }
736 if signed_at >= expiry {
737 fail.push(format!(
738 "signed_at '{}' is at or after mandate expiry '{}'",
739 stmt.timestamp, m.expiry
740 ));
741 }
742 }
743 _ => fail.push(format!(
744 "mandate issued_at '{}' / expiry '{}' are not both RFC 3339",
745 m.issued_at, m.expiry
746 )),
747 }
748
749 match revocation.status(&m.grant_id, &m.revocation.path) {
752 RevocationStatus::NotRevoked => {}
753 RevocationStatus::RevokedAt(ts) => match parse_rfc3339_to_unix(&ts) {
754 Some(revoked_at) => {
755 if signed_at >= revoked_at {
756 fail.push(format!(
757 "grant was revoked at '{ts}'; signed_at '{}' is not before revocation",
758 stmt.timestamp
759 ));
760 }
761 }
762 None => unver.push(format!("revocation timestamp '{ts}' is not RFC 3339")),
763 },
764 RevocationStatus::Unknown(reason) => {
765 unver.push(format!("revocation could not be checked: {reason}"))
766 }
767 }
768
769 if !fail.is_empty() {
770 MandateVerdict::Fail(fail)
771 } else if !unver.is_empty() {
772 MandateVerdict::Unverified(unver)
773 } else {
774 MandateVerdict::Pass
775 }
776}
777
778pub trait WitnessAuthority {
791 fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
792}
793
794pub struct NoWitnessAuthority;
797
798impl WitnessAuthority for NoWitnessAuthority {
799 fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
800 false
801 }
802}
803
804#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct EffectVerdict {
811 pub effective_confidence: EffectConfidence,
817 pub claimed_confidence: Option<EffectConfidence>,
820 pub trusted_witnesses: usize,
822 pub notes: Vec<String>,
824 pub effective_finality: Option<EffectFinality>,
830 pub claimed_finality: Option<EffectFinality>,
832}
833
834impl EffectVerdict {
835 pub fn is_verified(&self) -> bool {
837 self.effective_confidence == EffectConfidence::Verified
838 }
839}
840
841pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
856 let effect = match &stmt.effect {
857 Some(e) => e,
858 None => {
859 return EffectVerdict {
860 effective_confidence: EffectConfidence::NotVerified,
861 claimed_confidence: None,
862 trusted_witnesses: 0,
863 notes: vec!["receipt carries no effect block; effect is unverified".into()],
864 effective_finality: None,
865 claimed_finality: None,
866 }
867 }
868 };
869
870 let mut notes: Vec<String> = Vec::new();
871
872 let trusted_witnesses = effect
873 .witnesses
874 .iter()
875 .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
876 .count();
877 let untrusted = effect.witnesses.len() - trusted_witnesses;
878 if untrusted > 0 {
879 notes.push(format!(
880 "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
881 effect.witnesses.len()
882 ));
883 }
884
885 let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
888
889 let claimed = effect.effect_confidence;
890 let effective = match claimed {
891 None => {
892 notes.push("actor recorded no effect_confidence; effect is unverified".into());
893 EffectConfidence::NotVerified
894 }
895 Some(EffectConfidence::Verified) if !has_evidence => {
896 notes.push(
897 "actor claimed Verified but bundled no independent evidence \
898 (no readback, no trusted witness); downgraded to NotVerified"
899 .into(),
900 );
901 EffectConfidence::NotVerified
902 }
903 Some(c) => c,
904 };
905
906 let claimed_finality = effect.finality;
916 let effective_finality = match claimed_finality {
917 Some(EffectFinality::Finalized) if !has_evidence => {
918 notes.push(
919 "actor claimed the effect Finalized but bundled no independent evidence \
920 (no readback, no trusted witness); downgraded to Indeterminate"
921 .into(),
922 );
923 Some(EffectFinality::Indeterminate)
924 }
925 other => other,
926 };
927
928 EffectVerdict {
929 effective_confidence: effective,
930 claimed_confidence: claimed,
931 trusted_witnesses,
932 notes,
933 effective_finality,
934 claimed_finality,
935 }
936}
937
938#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
948pub struct Grant {
949 pub grant_id: String,
950 pub grantor: String,
952 #[serde(default)]
953 pub scope: Vec<String>,
954 pub audience: String,
955 #[serde(default, skip_serializing_if = "Option::is_none")]
956 pub parent_request_id: Option<String>,
957 #[serde(default)]
958 pub delegation_depth: u32,
959 pub issued_at: String,
960 pub expiry: String,
961 #[serde(default)]
962 pub max_delegation: u32,
963 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub objective_hash: Option<String>,
965
966 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub issuer_sig: Option<String>,
972
973 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub parent_grant_id: Option<String>,
979}
980
981impl Grant {
982 pub fn canonical_for_signing(&self) -> String {
987 let scope_digest = canonical_json_digest(&self.scope);
988 format!(
994 "v2|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
995 self.grantor,
996 scope_digest,
997 self.audience,
998 self.parent_request_id.as_deref().unwrap_or(""),
999 self.parent_grant_id.as_deref().unwrap_or(""),
1000 self.delegation_depth,
1001 self.issued_at,
1002 self.expiry,
1003 self.max_delegation,
1004 self.objective_hash.as_deref().unwrap_or(""),
1005 )
1006 }
1007
1008 pub fn derive_grant_id(&self) -> String {
1015 let digest = Sha256::digest(self.canonical_for_signing().as_bytes());
1016 format!("grn_{}", hex::encode(&digest[..8]))
1017 }
1018
1019 pub fn id_is_consistent(&self) -> bool {
1023 self.grant_id == self.derive_grant_id()
1024 }
1025
1026 pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
1030 let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
1031 Ok(URL_SAFE_NO_PAD.encode(sig))
1032 }
1033
1034 pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
1039 if !self.id_is_consistent() {
1043 return false;
1044 }
1045 let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
1046 Ok(b) if b.len() == 32 => b,
1047 _ => return false,
1048 };
1049 let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
1050 Ok(b) if b.len() == 64 => b,
1051 _ => return false,
1052 };
1053 let mut pk = [0u8; 32];
1054 pk.copy_from_slice(&pk_bytes);
1055 let mut sig = [0u8; 64];
1056 sig.copy_from_slice(&sig_bytes);
1057 let vk = match VerifyingKey::from_bytes(&pk) {
1058 Ok(k) => k,
1059 Err(_) => return false,
1060 };
1061 vk.verify_strict(
1062 self.canonical_for_signing().as_bytes(),
1063 &Signature::from_bytes(&sig),
1064 )
1065 .is_ok()
1066 }
1067}
1068
1069#[derive(Debug, Clone, PartialEq, Eq)]
1071pub enum ChainResolveError {
1072 InconsistentId { grant_id: String },
1074 LeafMissing { grant_id: String },
1076 AncestorMissing { parent_grant_id: String },
1078 Cycle { grant_id: String },
1080 UnreachableExtras { count: usize },
1084 Unsigned { grant_id: String },
1086 BadSignature { grant_id: String },
1088}
1089
1090impl std::fmt::Display for ChainResolveError {
1091 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1095 match self {
1096 Self::InconsistentId { grant_id } => {
1097 write!(f, "grant {grant_id} declares an id that does not match its content")
1098 }
1099 Self::LeafMissing { grant_id } => {
1100 write!(f, "the mandate names grant {grant_id}, which is not in the carried chain")
1101 }
1102 Self::AncestorMissing { parent_grant_id } => {
1103 write!(f, "parent grant {parent_grant_id} is missing from the chain")
1104 }
1105 Self::Cycle { grant_id } => {
1106 write!(f, "parent links revisit grant {grant_id}: the chain is a cycle")
1107 }
1108 Self::UnreachableExtras { count } => {
1109 write!(f, "{count} carried grant(s) are not reachable from the mandate")
1110 }
1111 Self::Unsigned { grant_id } => {
1112 write!(f, "grant {grant_id} carries no issuer signature")
1113 }
1114 Self::BadSignature { grant_id } => {
1115 write!(f, "grant {grant_id} has a signature that does not verify")
1116 }
1117 }
1118 }
1119}
1120
1121impl std::error::Error for ChainResolveError {}
1122
1123pub fn resolve_grant_chain(mandate: &Mandate) -> Result<Vec<Grant>, ChainResolveError> {
1136 use std::collections::{HashMap, HashSet};
1137
1138 let mut by_id: HashMap<String, &Grant> = HashMap::new();
1140 for g in &mandate.chain {
1141 if !g.id_is_consistent() {
1142 return Err(ChainResolveError::InconsistentId {
1143 grant_id: g.grant_id.clone(),
1144 });
1145 }
1146 let sig = match g.issuer_sig.as_deref() {
1147 Some(s) if !s.is_empty() => s,
1148 _ => {
1149 return Err(ChainResolveError::Unsigned {
1150 grant_id: g.grant_id.clone(),
1151 })
1152 }
1153 };
1154 if !g.verify_canonical(sig) {
1155 return Err(ChainResolveError::BadSignature {
1156 grant_id: g.grant_id.clone(),
1157 });
1158 }
1159 by_id.insert(g.grant_id.clone(), g);
1160 }
1161
1162 let mut leaf_first: Vec<Grant> = Vec::new();
1164 let mut seen: HashSet<String> = HashSet::new();
1165 let mut cursor = Some(mandate.grant_id.clone());
1166
1167 while let Some(id) = cursor {
1168 if !seen.insert(id.clone()) {
1169 return Err(ChainResolveError::Cycle { grant_id: id });
1170 }
1171 let g = match by_id.get(&id) {
1172 Some(g) => *g,
1173 None => {
1174 return Err(if leaf_first.is_empty() {
1175 ChainResolveError::LeafMissing { grant_id: id }
1176 } else {
1177 ChainResolveError::AncestorMissing {
1178 parent_grant_id: id,
1179 }
1180 })
1181 }
1182 };
1183 leaf_first.push(g.clone());
1184 cursor = g.parent_grant_id.clone();
1185 }
1186
1187 if seen.len() != by_id.len() {
1189 return Err(ChainResolveError::UnreachableExtras {
1190 count: by_id.len() - seen.len(),
1191 });
1192 }
1193
1194 leaf_first.reverse(); Ok(leaf_first)
1196}
1197
1198#[derive(Debug, Clone, PartialEq, Eq)]
1200pub enum GrantChainError {
1201 Empty,
1203 BadTimestamp { index: usize },
1205 ScopeWidened { parent: usize },
1207 ExpiryWidened { parent: usize },
1209 DepthNotIncremented { parent: usize },
1211 DepthExceedsMax { parent: usize },
1213 AudienceChanged { parent: usize },
1215}
1216
1217impl std::fmt::Display for GrantChainError {
1218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1222 match self {
1223 Self::Empty => write!(f, "the chain is empty"),
1224 Self::BadTimestamp { index } => {
1225 write!(f, "grant at hop {index} has an unparseable issued_at/expiry")
1226 }
1227 Self::ScopeWidened { parent } => {
1228 write!(f, "scope widens at hop {}->{}", parent, parent + 1)
1229 }
1230 Self::ExpiryWidened { parent } => {
1231 write!(f, "expiry extends past the parent at hop {}->{}", parent, parent + 1)
1232 }
1233 Self::DepthNotIncremented { parent } => {
1234 write!(f, "delegation depth does not increment by one at hop {}->{}", parent, parent + 1)
1235 }
1236 Self::DepthExceedsMax { parent } => {
1237 write!(f, "delegation depth exceeds the parent's max_delegation at hop {}->{}", parent, parent + 1)
1238 }
1239 Self::AudienceChanged { parent } => {
1240 write!(f, "audience changes at hop {}->{}", parent, parent + 1)
1241 }
1242 }
1243 }
1244}
1245
1246impl std::error::Error for GrantChainError {}
1247
1248pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
1258 if chain.is_empty() {
1259 return Err(GrantChainError::Empty);
1260 }
1261
1262 for (i, g) in chain.iter().enumerate() {
1265 if parse_rfc3339_to_unix(&g.issued_at).is_none()
1266 || parse_rfc3339_to_unix(&g.expiry).is_none()
1267 {
1268 return Err(GrantChainError::BadTimestamp { index: i });
1269 }
1270 }
1271
1272 for (i, pair) in chain.windows(2).enumerate() {
1273 let parent = &pair[0];
1274 let child = &pair[1];
1275
1276 if !scope_subset(&child.scope, &parent.scope) {
1277 return Err(GrantChainError::ScopeWidened { parent: i });
1278 }
1279
1280 let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
1282 let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
1283 if child_expiry > parent_expiry {
1284 return Err(GrantChainError::ExpiryWidened { parent: i });
1285 }
1286
1287 if child.delegation_depth != parent.delegation_depth + 1 {
1288 return Err(GrantChainError::DepthNotIncremented { parent: i });
1289 }
1290 if child.delegation_depth > parent.max_delegation {
1291 return Err(GrantChainError::DepthExceedsMax { parent: i });
1292 }
1293
1294 if child.audience != parent.audience {
1295 return Err(GrantChainError::AudienceChanged { parent: i });
1296 }
1297 }
1298
1299 Ok(())
1300}
1301
1302fn scope_subset(child: &[String], parent: &[String]) -> bool {
1306 child
1307 .iter()
1308 .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
1309}
1310
1311fn scope_entry_covers(parent: &str, child: &str) -> bool {
1312 if parent == child {
1313 return true;
1314 }
1315 if let Some(parent_prefix) = parent.strip_suffix(".*") {
1316 let child_core = child.strip_suffix(".*").unwrap_or(child);
1319 child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
1320 } else {
1321 false
1322 }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327 use super::*;
1328 use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
1329
1330 #[test]
1331 fn effect_confidence_ceiling_gates_on_independent_evidence() {
1332 let with_evidence = Effect {
1334 readback: Some("sha256:observed".into()),
1335 effect_confidence: Some(EffectConfidence::Verified),
1336 ..Default::default()
1337 };
1338 assert!(with_evidence.has_independent_evidence());
1339 assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
1340
1341 let claim_only = Effect {
1344 output_hash: Some("sha256:out".into()),
1345 effect_confidence: Some(EffectConfidence::Verified),
1346 ..Default::default()
1347 };
1348 assert!(!claim_only.has_independent_evidence());
1349 assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
1350
1351 let honest_downgrade = Effect {
1353 effect_confidence: Some(EffectConfidence::Unknown),
1354 ..Default::default()
1355 };
1356 assert_eq!(
1357 honest_downgrade.evidence_ceiling(),
1358 EffectConfidence::NotVerified
1359 );
1360 }
1361
1362 #[test]
1363 fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
1364 let e = Effect {
1365 effect_confidence: Some(EffectConfidence::NotVerified),
1366 ..Default::default()
1367 };
1368 let j = serde_json::to_string(&e).unwrap();
1369 assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
1370
1371 let empty = Effect::default();
1373 assert!(!serde_json::to_string(&empty)
1374 .unwrap()
1375 .contains("effect_confidence"));
1376 }
1377
1378 struct TrustingWitnessAuthority;
1382 impl WitnessAuthority for TrustingWitnessAuthority {
1383 fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
1384 w.is_signed()
1385 && w.observer != actor
1386 && effect.readback.as_deref() == Some(w.observation.as_str())
1387 }
1388 }
1389
1390 #[test]
1391 fn verify_effect_downgrades_unbacked_verified_claim() {
1392 let mut s = good_stmt();
1394 s.actor = "agent://worker".into();
1395 s.effect = Some(Effect {
1396 output_hash: Some("sha256:out".into()),
1397 effect_confidence: Some(EffectConfidence::Verified),
1398 ..Default::default()
1399 });
1400 let v = verify_effect(&s, &NoWitnessAuthority);
1401 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1402 assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1403 assert!(!v.is_verified());
1404 assert!(
1405 v.notes.iter().any(|n| n.contains("downgraded")),
1406 "{:?}",
1407 v.notes
1408 );
1409 }
1410
1411 #[test]
1414 fn finality_and_confidence_are_independent_axes() {
1415 let mut s = good_stmt();
1419 s.effect = Some(Effect {
1420 output_hash: Some("sha256:out".into()),
1421 effect_confidence: Some(EffectConfidence::Partial),
1422 finality: Some(EffectFinality::Finalized),
1423 ..Default::default()
1424 });
1425 let v = verify_effect(&s, &NoWitnessAuthority);
1426 assert_eq!(v.effective_confidence, EffectConfidence::Partial);
1428 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1430 assert_eq!(v.claimed_finality, Some(EffectFinality::Finalized));
1431 }
1432
1433 #[test]
1434 fn unbacked_finalized_is_downgraded_to_indeterminate() {
1435 let mut s = good_stmt();
1440 s.effect = Some(Effect {
1441 output_hash: Some("sha256:out".into()),
1442 finality: Some(EffectFinality::Finalized),
1443 ..Default::default()
1444 });
1445 let v = verify_effect(&s, &NoWitnessAuthority);
1446 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1447 assert!(
1448 v.notes.iter().any(|n| n.contains("Finalized")),
1449 "the downgrade must be stated, not silent: {:?}",
1450 v.notes
1451 );
1452 }
1453
1454 #[test]
1455 fn finalized_backed_by_readback_survives() {
1456 let mut s = good_stmt();
1457 s.effect = Some(Effect {
1458 readback: Some("sha256:observed".into()),
1459 finality: Some(EffectFinality::Finalized),
1460 ..Default::default()
1461 });
1462 let v = verify_effect(&s, &NoWitnessAuthority);
1463 assert_eq!(v.effective_finality, Some(EffectFinality::Finalized));
1464 }
1465
1466 #[test]
1467 fn lesser_finality_claims_pass_through_unchanged() {
1468 for stage in [
1471 EffectFinality::NotAttempted,
1472 EffectFinality::Initiated,
1473 EffectFinality::Failed,
1474 EffectFinality::Indeterminate,
1475 ] {
1476 let mut s = good_stmt();
1477 s.effect = Some(Effect {
1478 finality: Some(stage),
1479 ..Default::default()
1480 });
1481 let v = verify_effect(&s, &NoWitnessAuthority);
1482 assert_eq!(v.effective_finality, Some(stage), "{stage:?} was altered");
1483 }
1484 }
1485
1486 #[test]
1487 fn not_attempted_is_the_no_authority_moved_receipt() {
1488 let e = Effect {
1492 input_hash: Some("sha256:req".into()),
1493 finality: Some(EffectFinality::NotAttempted),
1494 ..Default::default()
1495 };
1496 assert!(EffectFinality::NotAttempted.is_resolved());
1497 assert_eq!(check_resolution(&e, 4_000_000_000), ResolutionStatus::Resolved);
1498 }
1499
1500 fn open_effect(resolution: Option<Resolution>) -> Effect {
1503 Effect {
1504 finality: Some(EffectFinality::Initiated),
1505 resolution,
1506 ..Default::default()
1507 }
1508 }
1509
1510 #[test]
1511 fn unresolved_without_a_deadline_reports_indefinite() {
1512 assert_eq!(
1515 check_resolution(&open_effect(None), 1_800_000_000),
1516 ResolutionStatus::Indefinite
1517 );
1518 }
1519
1520 const DEADLINE: &str = "2026-07-20T11:00:00Z";
1524 fn deadline_unix() -> i64 {
1525 parse_rfc3339_to_unix(DEADLINE).expect("fixture deadline parses") as i64
1526 }
1527
1528 #[test]
1529 fn unresolved_past_its_deadline_reports_the_declared_event() {
1530 let e = open_effect(Some(Resolution {
1531 deadline: DEADLINE.into(),
1532 on_deadline: DeadlineEvent::Escalate,
1533 }));
1534 match check_resolution(&e, deadline_unix() + 90) {
1535 ResolutionStatus::Breached {
1536 on_deadline,
1537 seconds_overdue,
1538 } => {
1539 assert_eq!(on_deadline, DeadlineEvent::Escalate);
1540 assert_eq!(seconds_overdue, 90);
1541 }
1542 other => panic!("expected Breached, got {other:?}"),
1543 }
1544 }
1545
1546 #[test]
1547 fn unresolved_inside_its_window_is_pending() {
1548 let e = open_effect(Some(Resolution {
1549 deadline: DEADLINE.into(),
1550 on_deadline: DeadlineEvent::Timeout,
1551 }));
1552 match check_resolution(&e, deadline_unix() - 60) {
1553 ResolutionStatus::Pending { seconds_remaining } => {
1554 assert_eq!(seconds_remaining, 60)
1555 }
1556 other => panic!("expected Pending, got {other:?}"),
1557 }
1558 }
1559
1560 #[test]
1561 fn a_resolved_effect_cannot_breach() {
1562 let e = Effect {
1564 finality: Some(EffectFinality::Finalized),
1565 resolution: Some(Resolution {
1566 deadline: "2026-07-20T11:00:00Z".into(),
1567 on_deadline: DeadlineEvent::Tombstone,
1568 }),
1569 ..Default::default()
1570 };
1571 assert_eq!(check_resolution(&e, 4_000_000_000), ResolutionStatus::Resolved);
1572 }
1573
1574 #[test]
1575 fn unparseable_deadline_fails_toward_unknown() {
1576 let e = open_effect(Some(Resolution {
1579 deadline: "whenever".into(),
1580 on_deadline: DeadlineEvent::Timeout,
1581 }));
1582 assert_eq!(
1583 check_resolution(&e, 1_800_000_000),
1584 ResolutionStatus::BadDeadline
1585 );
1586 }
1587
1588 #[test]
1589 fn missing_finality_is_treated_as_unresolved() {
1590 let e = Effect {
1593 output_hash: Some("sha256:out".into()),
1594 ..Default::default()
1595 };
1596 assert_eq!(
1597 check_resolution(&e, 1_800_000_000),
1598 ResolutionStatus::Indefinite
1599 );
1600 }
1601
1602 #[test]
1603 fn finality_and_resolution_are_omitted_when_absent() {
1604 let json = serde_json::to_string(&Effect {
1606 output_hash: Some("sha256:out".into()),
1607 ..Default::default()
1608 })
1609 .unwrap();
1610 assert!(!json.contains("finality"), "{json}");
1611 assert!(!json.contains("resolution"), "{json}");
1612 }
1613
1614 #[test]
1615 fn verify_effect_honors_verified_backed_by_readback() {
1616 let mut s = good_stmt();
1617 s.effect = Some(Effect {
1618 readback: Some("sha256:observed".into()),
1619 effect_confidence: Some(EffectConfidence::Verified),
1620 ..Default::default()
1621 });
1622 let v = verify_effect(&s, &NoWitnessAuthority);
1623 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1624 assert!(v.is_verified());
1625 }
1626
1627 #[test]
1628 fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1629 let mut s = good_stmt();
1632 s.actor = "agent://worker".into();
1633 s.effect = Some(Effect {
1634 readback: Some("sha256:state".into()),
1635 effect_confidence: Some(EffectConfidence::Verified),
1636 witnesses: vec![Witness {
1637 observer: "agent://auditor".into(),
1638 observation: "sha256:state".into(),
1639 observed_at: Some("2026-07-20T10:00:00Z".into()),
1640 signature: Some("ed25519:sig".into()),
1641 }],
1642 ..Default::default()
1643 });
1644 let v = verify_effect(&s, &TrustingWitnessAuthority);
1645 assert_eq!(v.trusted_witnesses, 1);
1646 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1647
1648 let mut self_witness = s.clone();
1650 if let Some(e) = self_witness.effect.as_mut() {
1651 e.readback = None; e.witnesses[0].observer = "agent://worker".into();
1653 }
1654 let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1655 assert_eq!(v2.trusted_witnesses, 0);
1656 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1657 assert!(v2
1658 .notes
1659 .iter()
1660 .any(|n| n.contains("not independently trusted")));
1661 }
1662
1663 #[test]
1664 fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1665 for c in [
1668 EffectConfidence::Partial,
1669 EffectConfidence::Ambiguous,
1670 EffectConfidence::Unknown,
1671 EffectConfidence::NotVerified,
1672 ] {
1673 let mut s = good_stmt();
1674 s.effect = Some(Effect {
1675 effect_confidence: Some(c),
1676 ..Default::default()
1677 });
1678 let v = verify_effect(&s, &NoWitnessAuthority);
1679 assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1680 }
1681 }
1682
1683 #[test]
1684 fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1685 let s = good_stmt();
1687 assert!(s.effect.is_none());
1688 let v = verify_effect(&s, &NoWitnessAuthority);
1689 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1690 assert_eq!(v.claimed_confidence, None);
1691 assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1692
1693 let mut s2 = good_stmt();
1695 s2.effect = Some(Effect {
1696 output_hash: Some("sha256:out".into()),
1697 ..Default::default()
1698 });
1699 let v2 = verify_effect(&s2, &NoWitnessAuthority);
1700 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1701 assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1702 }
1703
1704 #[test]
1705 fn witness_does_not_inflate_evidence_ceiling() {
1706 let signed_witness = Witness {
1711 observer: "agent://auditor".into(),
1712 observation: "sha256:observed".into(),
1713 observed_at: Some("2026-07-20T10:00:00Z".into()),
1714 signature: Some("ed25519:sig".into()),
1715 };
1716 let e = Effect {
1717 witnesses: vec![signed_witness.clone()],
1718 effect_confidence: Some(EffectConfidence::Verified),
1719 ..Default::default()
1720 };
1721 assert!(!e.has_independent_evidence());
1722 assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1723 assert!(signed_witness.is_signed());
1726 assert_eq!(e.signed_witnesses().count(), 1);
1727
1728 let unsigned = Effect {
1730 witnesses: vec![Witness {
1731 observer: "agent://auditor".into(),
1732 observation: "sha256:observed".into(),
1733 ..Default::default()
1734 }],
1735 ..Default::default()
1736 };
1737 assert_eq!(unsigned.signed_witnesses().count(), 0);
1738 }
1739
1740 #[test]
1741 fn witnesses_serialize_and_omit_when_empty() {
1742 let empty = Effect::default();
1743 assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1744
1745 let e = Effect {
1746 witnesses: vec![Witness {
1747 observer: "key_9f2c".into(),
1748 observation: "sha256:obs".into(),
1749 observed_at: None,
1750 signature: Some("ed25519:sig".into()),
1751 }],
1752 ..Default::default()
1753 };
1754 let j = serde_json::to_string(&e).unwrap();
1755 assert!(j.contains("\"witnesses\":[{"), "{j}");
1756 assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1757 assert!(!j.contains("observed_at"), "{j}");
1759 let back: Effect = serde_json::from_str(&j).unwrap();
1760 assert_eq!(back.witnesses.len(), 1);
1761 assert!(back.witnesses[0].is_signed());
1762 }
1763
1764 #[test]
1765 fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1766 assert!(RuntimeIdentity::default().is_unbound());
1767
1768 let with_model = RuntimeIdentity {
1770 model: Some("claude-opus-4-8".into()),
1771 ..Default::default()
1772 };
1773 assert!(!with_model.is_unbound());
1774
1775 let with_prompt = RuntimeIdentity {
1776 system_prompt_hash: Some("sha256:sys".into()),
1777 ..Default::default()
1778 };
1779 assert!(!with_prompt.is_unbound());
1780 }
1781
1782 #[test]
1783 fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1784 let rt = RuntimeIdentity {
1785 provider: Some("anthropic".into()),
1786 model: Some("claude-opus-4-8".into()),
1787 tool_schema_hash: Some("sha256:tools".into()),
1788 system_prompt_hash: None,
1789 };
1790 let j = serde_json::to_string(&rt).unwrap();
1791 assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1792 assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1793 assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1794 assert!(!j.contains("system_prompt_hash"), "{j}");
1796
1797 let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
1799 assert_eq!(empty, "{}");
1800 let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
1801 assert!(back.is_unbound());
1802 }
1803
1804 #[test]
1805 fn runtime_is_omitted_from_statement_when_absent() {
1806 let s = good_stmt();
1809 assert!(s.runtime.is_none());
1810 let j = serde_json::to_string(&s).unwrap();
1811 assert!(!j.contains("runtime"), "{j}");
1812
1813 let mut with_rt = good_stmt();
1815 with_rt.runtime = Some(RuntimeIdentity {
1816 model: Some("claude-opus-4-8".into()),
1817 ..Default::default()
1818 });
1819 let j2 = serde_json::to_string(&with_rt).unwrap();
1820 assert!(j2.contains("\"runtime\""), "{j2}");
1821 let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
1822 assert_eq!(
1823 back.runtime.unwrap().model.as_deref(),
1824 Some("claude-opus-4-8")
1825 );
1826 }
1827
1828 fn base_mandate() -> Mandate {
1829 Mandate {
1830 grant_id: "grant_9c2f".into(),
1831 grantor: "key_parent".into(),
1832 issuer_sig: None,
1833 objective_hash: Some("sha256:abc".into()),
1834 scope: vec!["payments.charge".into()],
1835 audience: "acme-payments-api".into(),
1836 parent_request_id: Some("req_7d3e".into()),
1837 delegation_depth: 2,
1838 issued_at: "2026-07-11T19:50:00Z".into(),
1839 expiry: "2026-07-11T20:50:00Z".into(),
1840 max_delegation: 3,
1841 revocation: Revocation {
1842 path: "hub://acme/revocations".into(),
1843 revoked_at: None,
1844 },
1845 chain: Vec::new(),
1846 }
1847 }
1848
1849 fn good_stmt() -> ActionStatementV2 {
1852 let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
1853 s.timestamp = "2026-07-11T19:53:09Z".into();
1854 s.audience = Some("acme-payments-api".into());
1855 s
1856 }
1857
1858 struct StaticRevocation(RevocationStatus);
1859 impl RevocationSource for StaticRevocation {
1860 fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
1861 self.0.clone()
1862 }
1863 }
1864
1865 #[test]
1868 fn scope_exact_and_glob() {
1869 assert!(action_in_scope(
1870 "payments.charge",
1871 &["payments.charge".into()]
1872 ));
1873 assert!(action_in_scope("payments.charge", &["payments.*".into()]));
1874 assert!(action_in_scope("payments", &["payments.*".into()]));
1875 assert!(!action_in_scope(
1876 "payments.refund",
1877 &["payments.charge".into()]
1878 ));
1879 assert!(!action_in_scope("email.send", &["payments.*".into()]));
1880 assert!(!action_in_scope("anything", &["*".into()]));
1882 assert!(action_in_scope("*", &["*".into()]));
1883 }
1884
1885 #[test]
1886 fn empty_scope_authorizes_nothing() {
1887 let mut s = good_stmt();
1888 s.mandate.scope = vec![];
1889 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1890 MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
1891 v => panic!("empty scope must fail, got {v:?}"),
1892 }
1893 }
1894
1895 #[test]
1896 fn action_out_of_scope_fails() {
1897 let mut s = good_stmt();
1898 s.action = "payments.refund".into();
1899 assert!(matches!(
1900 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1901 MandateVerdict::Fail(_)
1902 ));
1903 }
1904
1905 #[test]
1908 fn audience_match_passes_layer() {
1909 let s = good_stmt();
1910 assert_eq!(
1911 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1912 MandateVerdict::Pass
1913 );
1914 }
1915
1916 #[test]
1917 fn audience_mismatch_fails() {
1918 let mut s = good_stmt();
1919 s.audience = Some("evil-api".into());
1920 assert!(matches!(
1921 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1922 MandateVerdict::Fail(_)
1923 ));
1924 }
1925
1926 #[test]
1927 fn missing_action_audience_is_unverified_not_pass() {
1928 let mut s = good_stmt();
1929 s.audience = None;
1930 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1931 MandateVerdict::Unverified(rs) => {
1932 assert!(rs.iter().any(|r| r.contains("recorded no audience")))
1933 }
1934 v => panic!("missing audience must be Unverified, got {v:?}"),
1935 }
1936 }
1937
1938 #[test]
1939 fn empty_mandate_audience_fails() {
1940 let mut s = good_stmt();
1941 s.mandate.audience = "".into();
1942 assert!(matches!(
1943 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1944 MandateVerdict::Fail(_)
1945 ));
1946 }
1947
1948 #[test]
1951 fn signed_before_issued_fails() {
1952 let mut s = good_stmt();
1953 s.timestamp = "2026-07-11T19:49:59Z".into(); assert!(matches!(
1955 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1956 MandateVerdict::Fail(_)
1957 ));
1958 }
1959
1960 #[test]
1961 fn signed_at_expiry_fails() {
1962 let mut s = good_stmt();
1963 s.timestamp = "2026-07-11T20:50:00Z".into(); assert!(matches!(
1965 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1966 MandateVerdict::Fail(_)
1967 ));
1968 }
1969
1970 #[test]
1971 fn signed_within_window_passes() {
1972 let s = good_stmt(); assert_eq!(
1974 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1975 MandateVerdict::Pass
1976 );
1977 }
1978
1979 #[test]
1980 fn malformed_timestamp_fails_closed() {
1981 let mut s = good_stmt();
1982 s.timestamp = "not-a-timestamp".into();
1983 assert!(matches!(
1984 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1985 MandateVerdict::Fail(_)
1986 ));
1987 }
1988
1989 #[test]
1992 fn revoked_after_signing_still_passes() {
1993 let s = good_stmt();
1996 let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
1997 assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
1998 }
1999
2000 #[test]
2001 fn revoked_before_signing_fails() {
2002 let s = good_stmt(); let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
2004 assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
2005 }
2006
2007 #[test]
2008 fn revocation_unknown_is_unverified() {
2009 let s = good_stmt();
2010 match verify_mandate(&s, &NoRevocationSource) {
2011 MandateVerdict::Unverified(rs) => {
2012 assert!(rs
2013 .iter()
2014 .any(|r| r.contains("revocation could not be checked")))
2015 }
2016 v => panic!("no revocation source must be Unverified, got {v:?}"),
2017 }
2018 }
2019
2020 #[test]
2021 fn fail_takes_precedence_over_unverified() {
2022 let mut s = good_stmt();
2025 s.action = "payments.refund".into();
2026 assert!(matches!(
2027 verify_mandate(&s, &NoRevocationSource),
2028 MandateVerdict::Fail(_)
2029 ));
2030 }
2031
2032 #[test]
2033 fn wrong_type_fails() {
2034 let mut s = good_stmt();
2035 s.type_ = "treeship/action/v1".into();
2036 assert!(matches!(
2037 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2038 MandateVerdict::Fail(_)
2039 ));
2040 }
2041
2042 #[test]
2045 fn mandate_is_bound_into_signature() {
2046 let signer = Ed25519Signer::generate("key_test").unwrap();
2047 let pt = payload_type_v2("action");
2048
2049 let a = good_stmt();
2050 let mut b = good_stmt();
2051 b.mandate.scope = vec!["payments.*".into()]; let ra = sign(&pt, &a, &signer).unwrap();
2054 let rb = sign(&pt, &b, &signer).unwrap();
2055 assert_ne!(
2056 ra.artifact_id, rb.artifact_id,
2057 "changing mandate.scope must change the signed artifact id"
2058 );
2059 }
2060
2061 #[test]
2062 fn v2_sign_verify_roundtrip() {
2063 let signer = Ed25519Signer::generate("key_test").unwrap();
2064 let verifier = EnvVerifier::from_signer(&signer);
2065 let pt = payload_type_v2("action");
2066
2067 let mut s = good_stmt();
2068 s.effect = Some(Effect {
2069 output_hash: Some("sha256:out".into()),
2070 readback: Some("sha256:observed".into()),
2071 bytes_moved: Some(1_048_576),
2072 cost: Some(Cost {
2073 unit: "usd_micros".into(),
2074 amount: 4200,
2075 }),
2076 side_effects: vec!["db:users.update".into()],
2077 ..Default::default()
2078 });
2079
2080 let signed = sign(&pt, &s, &signer).unwrap();
2081 verifier.verify(&signed.envelope).unwrap();
2082
2083 let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
2084 assert_eq!(decoded.type_, TYPE_ACTION_V2);
2085 assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
2086 assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
2087 }
2088
2089 #[test]
2090 fn v2_payload_type_differs_from_v1() {
2091 assert_eq!(
2092 payload_type_v2("action"),
2093 "application/vnd.treeship.action.v2+json"
2094 );
2095 assert_ne!(
2096 payload_type_v2("action"),
2097 super::super::payload_type("action")
2098 );
2099 }
2100
2101 fn grant(
2104 id: &str,
2105 grantor: &str,
2106 scope: &[&str],
2107 depth: u32,
2108 expiry: &str,
2109 max_deleg: u32,
2110 ) -> Grant {
2111 Grant {
2112 grant_id: id.into(),
2113 grantor: grantor.into(),
2114 issuer_sig: None,
2115 scope: scope.iter().map(|s| (*s).into()).collect(),
2116 audience: "acme-payments-api".into(),
2117 parent_request_id: None,
2118 parent_grant_id: None,
2119 delegation_depth: depth,
2120 issued_at: "2026-07-11T19:00:00Z".into(),
2121 expiry: expiry.into(),
2122 max_delegation: max_deleg,
2123 objective_hash: None,
2124 }
2125 }
2126
2127 #[test]
2128 fn grant_sign_verify_roundtrip_and_tamper() {
2129 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2130 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2131 let mut g = grant(
2132 "grant_root",
2133 &grantor,
2134 &["payments.*"],
2135 0,
2136 "2026-07-11T21:00:00Z",
2137 3,
2138 );
2139 g.grant_id = g.derive_grant_id();
2142
2143 let sig = g.sign_canonical(&signer).unwrap();
2144 assert!(g.verify_canonical(&sig));
2145
2146 g.scope.push("email.*".into());
2148 assert!(!g.verify_canonical(&sig));
2149 }
2150
2151 #[test]
2152 fn grant_verify_rejects_wrong_key() {
2153 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2154 let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
2155 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2156 let g = grant(
2157 "grant_root",
2158 &grantor,
2159 &["payments.*"],
2160 0,
2161 "2026-07-11T21:00:00Z",
2162 3,
2163 );
2164 let sig = g.sign_canonical(&attacker).unwrap();
2165 assert!(!g.verify_canonical(&sig));
2166 }
2167
2168 #[test]
2169 fn valid_attenuating_chain_ok() {
2170 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2171 let child = grant(
2172 "g1",
2173 "k",
2174 &["payments.charge"],
2175 1,
2176 "2026-07-11T20:30:00Z",
2177 3,
2178 );
2179 assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
2180 }
2181
2182 #[test]
2183 fn scope_widening_rejected() {
2184 let root = grant(
2185 "g0",
2186 "k",
2187 &["payments.charge"],
2188 0,
2189 "2026-07-11T21:00:00Z",
2190 3,
2191 );
2192 let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
2193 assert_eq!(
2194 verify_grant_chain(&[root, child]),
2195 Err(GrantChainError::ScopeWidened { parent: 0 })
2196 );
2197 }
2198
2199 #[test]
2200 fn expiry_widening_rejected() {
2201 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2202 let child = grant(
2203 "g1",
2204 "k",
2205 &["payments.charge"],
2206 1,
2207 "2026-07-11T22:00:00Z",
2208 3,
2209 );
2210 assert_eq!(
2211 verify_grant_chain(&[root, child]),
2212 Err(GrantChainError::ExpiryWidened { parent: 0 })
2213 );
2214 }
2215
2216 #[test]
2217 fn depth_not_incremented_rejected() {
2218 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2219 let child = grant(
2220 "g1",
2221 "k",
2222 &["payments.charge"],
2223 2,
2224 "2026-07-11T21:00:00Z",
2225 3,
2226 );
2227 assert_eq!(
2228 verify_grant_chain(&[root, child]),
2229 Err(GrantChainError::DepthNotIncremented { parent: 0 })
2230 );
2231 }
2232
2233 #[test]
2234 fn depth_exceeds_max_rejected() {
2235 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
2236 let child = grant(
2237 "g1",
2238 "k",
2239 &["payments.charge"],
2240 1,
2241 "2026-07-11T21:00:00Z",
2242 0,
2243 );
2244 assert_eq!(
2245 verify_grant_chain(&[root, child]),
2246 Err(GrantChainError::DepthExceedsMax { parent: 0 })
2247 );
2248 }
2249
2250 #[test]
2251 fn audience_change_rejected() {
2252 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2253 let mut child = grant(
2254 "g1",
2255 "k",
2256 &["payments.charge"],
2257 1,
2258 "2026-07-11T21:00:00Z",
2259 3,
2260 );
2261 child.audience = "other-api".into();
2262 assert_eq!(
2263 verify_grant_chain(&[root, child]),
2264 Err(GrantChainError::AudienceChanged { parent: 0 })
2265 );
2266 }
2267
2268 #[test]
2269 fn empty_chain_rejected() {
2270 assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
2271 }
2272
2273 #[test]
2274 fn bad_timestamp_in_chain_rejected() {
2275 let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2276 root.expiry = "nope".into();
2277 assert_eq!(
2278 verify_grant_chain(&[root]),
2279 Err(GrantChainError::BadTimestamp { index: 0 })
2280 );
2281 }
2282
2283 #[test]
2284 fn single_grant_chain_ok() {
2285 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2286 assert_eq!(verify_grant_chain(&[root]), Ok(()));
2287 }
2288
2289 fn mk_grant(
2294 signer: &Ed25519Signer,
2295 grantor_pk: &str,
2296 scope: Vec<&str>,
2297 depth: u32,
2298 parent: Option<&str>,
2299 ) -> Grant {
2300 let mut g = Grant {
2301 grant_id: String::new(),
2302 grantor: grantor_pk.to_string(),
2303 issuer_sig: None,
2304 scope: scope.into_iter().map(String::from).collect(),
2305 audience: "acme".into(),
2306 parent_request_id: None,
2307 parent_grant_id: parent.map(String::from),
2308 delegation_depth: depth,
2309 issued_at: "2026-07-20T10:00:00Z".into(),
2310 expiry: "2026-07-20T11:00:00Z".into(),
2311 max_delegation: 3,
2312 objective_hash: None,
2313 };
2314 g.grant_id = g.derive_grant_id();
2315 g.issuer_sig = Some(g.sign_canonical(signer).unwrap());
2316 g
2317 }
2318
2319 fn chain_fixture() -> (Grant, Grant, Ed25519Signer) {
2320 let signer = Ed25519Signer::generate("issuer").unwrap();
2321 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2322 let root = mk_grant(&signer, &pk, vec!["payments.*"], 0, None);
2323 let leaf = mk_grant(
2324 &signer,
2325 &pk,
2326 vec!["payments.charge"],
2327 1,
2328 Some(&root.grant_id),
2329 );
2330 (root, leaf, signer)
2331 }
2332
2333 fn mandate_with(leaf: &Grant, chain: Vec<Grant>) -> Mandate {
2334 let mut m = base_mandate();
2335 m.grant_id = leaf.grant_id.clone();
2336 m.chain = chain;
2337 m
2338 }
2339
2340 #[test]
2341 fn grant_id_is_content_derived_and_stable() {
2342 let (root, _, _) = chain_fixture();
2343 assert!(root.grant_id.starts_with("grn_"));
2344 assert_eq!(root.grant_id, root.derive_grant_id());
2345 let mut altered = root.clone();
2347 altered.scope = vec!["payments.refund".into()];
2348 assert_ne!(altered.derive_grant_id(), root.grant_id);
2349 }
2350
2351 #[test]
2352 fn hand_chosen_id_fails_verification() {
2353 let (mut root, _, _) = chain_fixture();
2354 let sig = root.issuer_sig.clone().unwrap();
2355 root.grant_id = "grn_deadbeefdeadbeef".into();
2356 assert!(
2357 !root.verify_canonical(&sig),
2358 "an id that was chosen rather than computed must not verify"
2359 );
2360 }
2361
2362 #[test]
2363 fn resolves_root_first_regardless_of_carrier_order() {
2364 let (root, leaf, _) = chain_fixture();
2365 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2367 let resolved = resolve_grant_chain(&m).expect("resolves");
2368 assert_eq!(resolved.len(), 2);
2369 assert_eq!(resolved[0].grant_id, root.grant_id, "root must come first");
2370 assert_eq!(resolved[1].grant_id, leaf.grant_id);
2371 }
2372
2373 #[test]
2374 fn truncated_chain_is_rejected() {
2375 let (_, leaf, _) = chain_fixture();
2376 let m = mandate_with(&leaf, vec![leaf.clone()]);
2379 match resolve_grant_chain(&m) {
2380 Err(ChainResolveError::AncestorMissing { .. }) => {}
2381 other => panic!("truncation must be caught, got {other:?}"),
2382 }
2383 }
2384
2385 #[test]
2386 fn spliced_decoy_grant_is_rejected() {
2387 let (root, leaf, signer) = chain_fixture();
2388 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2389 let decoy = mk_grant(&signer, &pk, vec!["email.send"], 0, None);
2393 assert_ne!(decoy.grant_id, root.grant_id);
2394 let m = mandate_with(&leaf, vec![root.clone(), leaf.clone(), decoy]);
2395 match resolve_grant_chain(&m) {
2396 Err(ChainResolveError::UnreachableExtras { count }) => assert_eq!(count, 1),
2397 other => panic!("unreachable extras must be refused, got {other:?}"),
2398 }
2399 }
2400
2401 #[test]
2402 fn unsigned_ancestor_is_rejected() {
2403 let (mut root, leaf, _) = chain_fixture();
2404 root.issuer_sig = None;
2405 let m = mandate_with(&leaf, vec![root, leaf.clone()]);
2406 assert!(matches!(
2407 resolve_grant_chain(&m),
2408 Err(ChainResolveError::Unsigned { .. })
2409 ));
2410 }
2411
2412 #[test]
2413 fn resolved_chain_feeds_attenuation_check() {
2414 let (root, leaf, _) = chain_fixture();
2417 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2418 let resolved = resolve_grant_chain(&m).expect("resolves");
2419 assert!(
2420 verify_grant_chain(&resolved).is_ok(),
2421 "narrowing scope at depth+1 must satisfy attenuation"
2422 );
2423 }
2424
2425 #[test]
2426 fn leaf_not_in_chain_is_rejected() {
2427 let (root, leaf, _) = chain_fixture();
2431 let mut m = mandate_with(&leaf, vec![root.clone()]);
2432 m.grant_id = leaf.grant_id.clone();
2433 match resolve_grant_chain(&m) {
2434 Err(ChainResolveError::LeafMissing { grant_id }) => {
2435 assert_eq!(grant_id, leaf.grant_id);
2436 }
2437 other => panic!("a mandate naming an absent leaf must fail, got {other:?}"),
2438 }
2439 }
2440
2441 #[test]
2442 fn ancestor_signed_by_a_stranger_is_rejected() {
2443 let (root, leaf, _) = chain_fixture();
2447 let stranger = Ed25519Signer::generate("stranger").unwrap();
2448 let mut forged = root.clone();
2449 forged.issuer_sig = Some(forged.sign_canonical(&stranger).unwrap());
2450 assert_eq!(
2451 forged.grant_id, root.grant_id,
2452 "signing with another key must not change the content id"
2453 );
2454
2455 let m = mandate_with(&leaf, vec![forged, leaf.clone()]);
2456 match resolve_grant_chain(&m) {
2457 Err(ChainResolveError::BadSignature { grant_id }) => {
2458 assert_eq!(grant_id, root.grant_id);
2459 }
2460 other => panic!("a grant signed by a non-grantor must fail, got {other:?}"),
2461 }
2462 }
2463
2464 #[test]
2465 fn inconsistent_id_is_caught_before_signature_check() {
2466 let (root, leaf, _) = chain_fixture();
2470 let mut tampered = root.clone();
2471 tampered.grant_id = "grn_0000000000000000".into();
2472 let m = mandate_with(&leaf, vec![tampered, leaf.clone()]);
2473 assert!(matches!(
2474 resolve_grant_chain(&m),
2475 Err(ChainResolveError::InconsistentId { .. })
2476 ));
2477 }
2478}