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.chain.is_empty() {
798 match resolve_grant_chain(m) {
799 Err(e) => fail.push(format!("grant chain does not resolve: {e}")),
800 Ok(chain) => {
801 if let Err(e) = verify_grant_chain(&chain) {
802 fail.push(format!("grant chain attenuation: {e}"));
803 } else if let Some(leaf) = chain.last() {
804 if !scope_subset(&m.scope, &leaf.scope) {
805 fail.push(format!(
806 "mandate scope {:?} exceeds the leaf grant's scope {:?}: the \
807 mandate claims authority the grantor did not give",
808 m.scope, leaf.scope
809 ));
810 }
811 if m.audience != leaf.audience {
812 fail.push(format!(
813 "mandate audience '{}' does not match the leaf grant's '{}'",
814 m.audience, leaf.audience
815 ));
816 }
817 match (&leaf.objective_hash, &m.objective_hash) {
821 (Some(g), Some(mm)) if g != mm => fail.push(format!(
822 "mandate objective '{mm}' does not match the leaf grant's '{g}'"
823 )),
824 (Some(g), None) => fail.push(format!(
825 "leaf grant is bound to objective '{g}' and the mandate declares \
826 none: dropping the binding removes the constraint"
827 )),
828 _ => {}
829 }
830 }
831 }
832 }
833 }
834
835 if m.grantee.as_deref().unwrap_or("").is_empty() {
845 unver.push(
846 "grant names no grantee (bearer): any holder of the grant could have produced this"
847 .into(),
848 );
849 }
850
851 if !fail.is_empty() {
852 MandateVerdict::Fail(fail)
853 } else if !unver.is_empty() {
854 MandateVerdict::Unverified(unver)
855 } else {
856 MandateVerdict::Pass
857 }
858}
859
860pub trait WitnessAuthority {
873 fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
874}
875
876pub struct NoWitnessAuthority;
879
880impl WitnessAuthority for NoWitnessAuthority {
881 fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
882 false
883 }
884}
885
886#[derive(Debug, Clone, PartialEq, Eq)]
892pub struct EffectVerdict {
893 pub effective_confidence: EffectConfidence,
899 pub claimed_confidence: Option<EffectConfidence>,
902 pub trusted_witnesses: usize,
904 pub notes: Vec<String>,
906 pub effective_finality: Option<EffectFinality>,
912 pub claimed_finality: Option<EffectFinality>,
914}
915
916impl EffectVerdict {
917 pub fn is_verified(&self) -> bool {
919 self.effective_confidence == EffectConfidence::Verified
920 }
921}
922
923pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
938 let effect = match &stmt.effect {
939 Some(e) => e,
940 None => {
941 return EffectVerdict {
942 effective_confidence: EffectConfidence::NotVerified,
943 claimed_confidence: None,
944 trusted_witnesses: 0,
945 notes: vec!["receipt carries no effect block; effect is unverified".into()],
946 effective_finality: None,
947 claimed_finality: None,
948 }
949 }
950 };
951
952 let mut notes: Vec<String> = Vec::new();
953
954 let trusted_witnesses = effect
955 .witnesses
956 .iter()
957 .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
958 .count();
959 let untrusted = effect.witnesses.len() - trusted_witnesses;
960 if untrusted > 0 {
961 notes.push(format!(
962 "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
963 effect.witnesses.len()
964 ));
965 }
966
967 let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
970
971 let claimed = effect.effect_confidence;
972 let effective = match claimed {
973 None => {
974 notes.push("actor recorded no effect_confidence; effect is unverified".into());
975 EffectConfidence::NotVerified
976 }
977 Some(EffectConfidence::Verified) if !has_evidence => {
978 notes.push(
979 "actor claimed Verified but bundled no independent evidence \
980 (no readback, no trusted witness); downgraded to NotVerified"
981 .into(),
982 );
983 EffectConfidence::NotVerified
984 }
985 Some(c) => c,
986 };
987
988 let claimed_finality = effect.finality;
998 let effective_finality = match claimed_finality {
999 Some(EffectFinality::Finalized) if !has_evidence => {
1000 notes.push(
1001 "actor claimed the effect Finalized but bundled no independent evidence \
1002 (no readback, no trusted witness); downgraded to Indeterminate"
1003 .into(),
1004 );
1005 Some(EffectFinality::Indeterminate)
1006 }
1007 other => other,
1008 };
1009
1010 EffectVerdict {
1011 effective_confidence: effective,
1012 claimed_confidence: claimed,
1013 trusted_witnesses,
1014 notes,
1015 effective_finality,
1016 claimed_finality,
1017 }
1018}
1019
1020#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1030pub struct Grant {
1031 pub grant_id: String,
1032 pub grantor: String,
1034 #[serde(default)]
1035 pub scope: Vec<String>,
1036 pub audience: String,
1037 #[serde(default, skip_serializing_if = "Option::is_none")]
1038 pub parent_request_id: Option<String>,
1039 #[serde(default)]
1040 pub delegation_depth: u32,
1041 pub issued_at: String,
1042 pub expiry: String,
1043 #[serde(default)]
1044 pub max_delegation: u32,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub objective_hash: Option<String>,
1047
1048 #[serde(default, skip_serializing_if = "Option::is_none")]
1053 pub issuer_sig: Option<String>,
1054
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1060 pub parent_grant_id: Option<String>,
1061
1062 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub grantee: Option<String>,
1076}
1077
1078impl Grant {
1079 pub fn canonical_for_signing(&self) -> String {
1084 let scope_digest = canonical_json_digest(&self.scope);
1085 format!(
1096 "v3|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
1097 self.grantor,
1098 scope_digest,
1099 self.audience,
1100 self.parent_request_id.as_deref().unwrap_or(""),
1101 self.parent_grant_id.as_deref().unwrap_or(""),
1102 self.grantee.as_deref().unwrap_or(""),
1103 self.delegation_depth,
1104 self.issued_at,
1105 self.expiry,
1106 self.max_delegation,
1107 self.objective_hash.as_deref().unwrap_or(""),
1108 )
1109 }
1110
1111 pub fn binds_holder(&self) -> bool {
1118 self.grantee.as_deref().is_some_and(|g| !g.is_empty())
1119 }
1120
1121 pub fn exercisable_by(&self, holder_pubkey: &str) -> bool {
1125 match self.grantee.as_deref() {
1126 Some(g) if !g.is_empty() => g == holder_pubkey,
1127 _ => true,
1128 }
1129 }
1130
1131 pub fn derive_grant_id(&self) -> String {
1138 let digest = Sha256::digest(self.canonical_for_signing().as_bytes());
1139 format!("grn_{}", hex::encode(&digest[..8]))
1140 }
1141
1142 pub fn id_is_consistent(&self) -> bool {
1146 self.grant_id == self.derive_grant_id()
1147 }
1148
1149 pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
1153 let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
1154 Ok(URL_SAFE_NO_PAD.encode(sig))
1155 }
1156
1157 pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
1162 if !self.id_is_consistent() {
1166 return false;
1167 }
1168 let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
1169 Ok(b) if b.len() == 32 => b,
1170 _ => return false,
1171 };
1172 let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
1173 Ok(b) if b.len() == 64 => b,
1174 _ => return false,
1175 };
1176 let mut pk = [0u8; 32];
1177 pk.copy_from_slice(&pk_bytes);
1178 let mut sig = [0u8; 64];
1179 sig.copy_from_slice(&sig_bytes);
1180 let vk = match VerifyingKey::from_bytes(&pk) {
1181 Ok(k) => k,
1182 Err(_) => return false,
1183 };
1184 vk.verify_strict(
1185 self.canonical_for_signing().as_bytes(),
1186 &Signature::from_bytes(&sig),
1187 )
1188 .is_ok()
1189 }
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq)]
1194pub enum ChainResolveError {
1195 InconsistentId { grant_id: String },
1197 LeafMissing { grant_id: String },
1199 AncestorMissing { parent_grant_id: String },
1201 Cycle { grant_id: String },
1203 UnreachableExtras { count: usize },
1207 Unsigned { grant_id: String },
1209 BadSignature { grant_id: String },
1211}
1212
1213impl std::fmt::Display for ChainResolveError {
1214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1218 match self {
1219 Self::InconsistentId { grant_id } => {
1220 write!(
1221 f,
1222 "grant {grant_id} declares an id that does not match its content"
1223 )
1224 }
1225 Self::LeafMissing { grant_id } => {
1226 write!(
1227 f,
1228 "the mandate names grant {grant_id}, which is not in the carried chain"
1229 )
1230 }
1231 Self::AncestorMissing { parent_grant_id } => {
1232 write!(
1233 f,
1234 "parent grant {parent_grant_id} is missing from the chain"
1235 )
1236 }
1237 Self::Cycle { grant_id } => {
1238 write!(
1239 f,
1240 "parent links revisit grant {grant_id}: the chain is a cycle"
1241 )
1242 }
1243 Self::UnreachableExtras { count } => {
1244 write!(
1245 f,
1246 "{count} carried grant(s) are not reachable from the mandate"
1247 )
1248 }
1249 Self::Unsigned { grant_id } => {
1250 write!(f, "grant {grant_id} carries no issuer signature")
1251 }
1252 Self::BadSignature { grant_id } => {
1253 write!(f, "grant {grant_id} has a signature that does not verify")
1254 }
1255 }
1256 }
1257}
1258
1259impl std::error::Error for ChainResolveError {}
1260
1261pub fn resolve_grant_chain(mandate: &Mandate) -> Result<Vec<Grant>, ChainResolveError> {
1274 use std::collections::{HashMap, HashSet};
1275
1276 let mut by_id: HashMap<String, &Grant> = HashMap::new();
1278 for g in &mandate.chain {
1279 if !g.id_is_consistent() {
1280 return Err(ChainResolveError::InconsistentId {
1281 grant_id: g.grant_id.clone(),
1282 });
1283 }
1284 let sig = match g.issuer_sig.as_deref() {
1285 Some(s) if !s.is_empty() => s,
1286 _ => {
1287 return Err(ChainResolveError::Unsigned {
1288 grant_id: g.grant_id.clone(),
1289 })
1290 }
1291 };
1292 if !g.verify_canonical(sig) {
1293 return Err(ChainResolveError::BadSignature {
1294 grant_id: g.grant_id.clone(),
1295 });
1296 }
1297 by_id.insert(g.grant_id.clone(), g);
1298 }
1299
1300 let mut leaf_first: Vec<Grant> = Vec::new();
1302 let mut seen: HashSet<String> = HashSet::new();
1303 let mut cursor = Some(mandate.grant_id.clone());
1304
1305 while let Some(id) = cursor {
1306 if !seen.insert(id.clone()) {
1307 return Err(ChainResolveError::Cycle { grant_id: id });
1308 }
1309 let g = match by_id.get(&id) {
1310 Some(g) => *g,
1311 None => {
1312 return Err(if leaf_first.is_empty() {
1313 ChainResolveError::LeafMissing { grant_id: id }
1314 } else {
1315 ChainResolveError::AncestorMissing {
1316 parent_grant_id: id,
1317 }
1318 })
1319 }
1320 };
1321 leaf_first.push(g.clone());
1322 cursor = g.parent_grant_id.clone();
1323 }
1324
1325 if seen.len() != by_id.len() {
1327 return Err(ChainResolveError::UnreachableExtras {
1328 count: by_id.len() - seen.len(),
1329 });
1330 }
1331
1332 leaf_first.reverse(); Ok(leaf_first)
1334}
1335
1336#[derive(Debug, Clone, PartialEq, Eq)]
1338pub enum GrantChainError {
1339 Empty,
1341 BadTimestamp { index: usize },
1343 ScopeWidened { parent: usize },
1345 ExpiryWidened { parent: usize },
1347 DepthNotIncremented { parent: usize },
1349 DepthExceedsMax { parent: usize },
1351 AudienceChanged { parent: usize },
1353 ObjectiveChanged { parent: usize },
1365}
1366
1367impl std::fmt::Display for GrantChainError {
1368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1372 match self {
1373 Self::Empty => write!(f, "the chain is empty"),
1374 Self::BadTimestamp { index } => {
1375 write!(
1376 f,
1377 "grant at hop {index} has an unparseable issued_at/expiry"
1378 )
1379 }
1380 Self::ScopeWidened { parent } => {
1381 write!(f, "scope widens at hop {}->{}", parent, parent + 1)
1382 }
1383 Self::ExpiryWidened { parent } => {
1384 write!(
1385 f,
1386 "expiry extends past the parent at hop {}->{}",
1387 parent,
1388 parent + 1
1389 )
1390 }
1391 Self::DepthNotIncremented { parent } => {
1392 write!(
1393 f,
1394 "delegation depth does not increment by one at hop {}->{}",
1395 parent,
1396 parent + 1
1397 )
1398 }
1399 Self::DepthExceedsMax { parent } => {
1400 write!(
1401 f,
1402 "delegation depth exceeds the parent's max_delegation at hop {}->{}",
1403 parent,
1404 parent + 1
1405 )
1406 }
1407 Self::ObjectiveChanged { parent } => write!(
1408 f,
1409 "objective changed or was dropped at hop {}->{}: authority granted for one \
1410 task cannot be spent on another",
1411 parent,
1412 parent + 1
1413 ),
1414 Self::AudienceChanged { parent } => {
1415 write!(f, "audience changes at hop {}->{}", parent, parent + 1)
1416 }
1417 }
1418 }
1419}
1420
1421impl std::error::Error for GrantChainError {}
1422
1423pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
1433 if chain.is_empty() {
1434 return Err(GrantChainError::Empty);
1435 }
1436
1437 for (i, g) in chain.iter().enumerate() {
1440 if parse_rfc3339_to_unix(&g.issued_at).is_none()
1441 || parse_rfc3339_to_unix(&g.expiry).is_none()
1442 {
1443 return Err(GrantChainError::BadTimestamp { index: i });
1444 }
1445 }
1446
1447 for (i, pair) in chain.windows(2).enumerate() {
1448 let parent = &pair[0];
1449 let child = &pair[1];
1450
1451 if !scope_subset(&child.scope, &parent.scope) {
1452 return Err(GrantChainError::ScopeWidened { parent: i });
1453 }
1454
1455 let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
1457 let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
1458 if child_expiry > parent_expiry {
1459 return Err(GrantChainError::ExpiryWidened { parent: i });
1460 }
1461
1462 if child.delegation_depth != parent.delegation_depth + 1 {
1463 return Err(GrantChainError::DepthNotIncremented { parent: i });
1464 }
1465 if child.delegation_depth > parent.max_delegation {
1466 return Err(GrantChainError::DepthExceedsMax { parent: i });
1467 }
1468
1469 if child.audience != parent.audience {
1470 return Err(GrantChainError::AudienceChanged { parent: i });
1471 }
1472
1473 match (&parent.objective_hash, &child.objective_hash) {
1478 (Some(p), Some(c)) if p != c => {
1479 return Err(GrantChainError::ObjectiveChanged { parent: i });
1480 }
1481 (Some(_), None) => {
1482 return Err(GrantChainError::ObjectiveChanged { parent: i });
1483 }
1484 _ => {}
1485 }
1486 }
1487
1488 Ok(())
1489}
1490
1491fn scope_subset(child: &[String], parent: &[String]) -> bool {
1495 child
1496 .iter()
1497 .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
1498}
1499
1500fn scope_entry_covers(parent: &str, child: &str) -> bool {
1501 if parent == child {
1502 return true;
1503 }
1504 if let Some(parent_prefix) = parent.strip_suffix(".*") {
1505 let child_core = child.strip_suffix(".*").unwrap_or(child);
1508 child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
1509 } else {
1510 false
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
1518
1519 #[test]
1520 fn effect_confidence_ceiling_gates_on_independent_evidence() {
1521 let with_evidence = Effect {
1523 readback: Some("sha256:observed".into()),
1524 effect_confidence: Some(EffectConfidence::Verified),
1525 ..Default::default()
1526 };
1527 assert!(with_evidence.has_independent_evidence());
1528 assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
1529
1530 let claim_only = Effect {
1533 output_hash: Some("sha256:out".into()),
1534 effect_confidence: Some(EffectConfidence::Verified),
1535 ..Default::default()
1536 };
1537 assert!(!claim_only.has_independent_evidence());
1538 assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
1539
1540 let honest_downgrade = Effect {
1542 effect_confidence: Some(EffectConfidence::Unknown),
1543 ..Default::default()
1544 };
1545 assert_eq!(
1546 honest_downgrade.evidence_ceiling(),
1547 EffectConfidence::NotVerified
1548 );
1549 }
1550
1551 #[test]
1552 fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
1553 let e = Effect {
1554 effect_confidence: Some(EffectConfidence::NotVerified),
1555 ..Default::default()
1556 };
1557 let j = serde_json::to_string(&e).unwrap();
1558 assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
1559
1560 let empty = Effect::default();
1562 assert!(!serde_json::to_string(&empty)
1563 .unwrap()
1564 .contains("effect_confidence"));
1565 }
1566
1567 struct TrustingWitnessAuthority;
1571 impl WitnessAuthority for TrustingWitnessAuthority {
1572 fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
1573 w.is_signed()
1574 && w.observer != actor
1575 && effect.readback.as_deref() == Some(w.observation.as_str())
1576 }
1577 }
1578
1579 #[test]
1580 fn verify_effect_downgrades_unbacked_verified_claim() {
1581 let mut s = good_stmt();
1583 s.actor = "agent://worker".into();
1584 s.effect = Some(Effect {
1585 output_hash: Some("sha256:out".into()),
1586 effect_confidence: Some(EffectConfidence::Verified),
1587 ..Default::default()
1588 });
1589 let v = verify_effect(&s, &NoWitnessAuthority);
1590 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1591 assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1592 assert!(!v.is_verified());
1593 assert!(
1594 v.notes.iter().any(|n| n.contains("downgraded")),
1595 "{:?}",
1596 v.notes
1597 );
1598 }
1599
1600 #[test]
1603 fn finality_and_confidence_are_independent_axes() {
1604 let mut s = good_stmt();
1608 s.effect = Some(Effect {
1609 output_hash: Some("sha256:out".into()),
1610 effect_confidence: Some(EffectConfidence::Partial),
1611 finality: Some(EffectFinality::Finalized),
1612 ..Default::default()
1613 });
1614 let v = verify_effect(&s, &NoWitnessAuthority);
1615 assert_eq!(v.effective_confidence, EffectConfidence::Partial);
1617 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1619 assert_eq!(v.claimed_finality, Some(EffectFinality::Finalized));
1620 }
1621
1622 #[test]
1623 fn unbacked_finalized_is_downgraded_to_indeterminate() {
1624 let mut s = good_stmt();
1629 s.effect = Some(Effect {
1630 output_hash: Some("sha256:out".into()),
1631 finality: Some(EffectFinality::Finalized),
1632 ..Default::default()
1633 });
1634 let v = verify_effect(&s, &NoWitnessAuthority);
1635 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1636 assert!(
1637 v.notes.iter().any(|n| n.contains("Finalized")),
1638 "the downgrade must be stated, not silent: {:?}",
1639 v.notes
1640 );
1641 }
1642
1643 #[test]
1644 fn finalized_backed_by_readback_survives() {
1645 let mut s = good_stmt();
1646 s.effect = Some(Effect {
1647 readback: Some("sha256:observed".into()),
1648 finality: Some(EffectFinality::Finalized),
1649 ..Default::default()
1650 });
1651 let v = verify_effect(&s, &NoWitnessAuthority);
1652 assert_eq!(v.effective_finality, Some(EffectFinality::Finalized));
1653 }
1654
1655 #[test]
1656 fn lesser_finality_claims_pass_through_unchanged() {
1657 for stage in [
1660 EffectFinality::NotAttempted,
1661 EffectFinality::Initiated,
1662 EffectFinality::Failed,
1663 EffectFinality::Indeterminate,
1664 ] {
1665 let mut s = good_stmt();
1666 s.effect = Some(Effect {
1667 finality: Some(stage),
1668 ..Default::default()
1669 });
1670 let v = verify_effect(&s, &NoWitnessAuthority);
1671 assert_eq!(v.effective_finality, Some(stage), "{stage:?} was altered");
1672 }
1673 }
1674
1675 #[test]
1676 fn not_attempted_is_the_no_authority_moved_receipt() {
1677 let e = Effect {
1681 input_hash: Some("sha256:req".into()),
1682 finality: Some(EffectFinality::NotAttempted),
1683 ..Default::default()
1684 };
1685 assert!(EffectFinality::NotAttempted.is_resolved());
1686 assert_eq!(
1687 check_resolution(&e, 4_000_000_000),
1688 ResolutionStatus::Resolved
1689 );
1690 }
1691
1692 fn open_effect(resolution: Option<Resolution>) -> Effect {
1695 Effect {
1696 finality: Some(EffectFinality::Initiated),
1697 resolution,
1698 ..Default::default()
1699 }
1700 }
1701
1702 #[test]
1703 fn unresolved_without_a_deadline_reports_indefinite() {
1704 assert_eq!(
1707 check_resolution(&open_effect(None), 1_800_000_000),
1708 ResolutionStatus::Indefinite
1709 );
1710 }
1711
1712 const DEADLINE: &str = "2026-07-20T11:00:00Z";
1716 fn deadline_unix() -> i64 {
1717 parse_rfc3339_to_unix(DEADLINE).expect("fixture deadline parses") as i64
1718 }
1719
1720 #[test]
1721 fn unresolved_past_its_deadline_reports_the_declared_event() {
1722 let e = open_effect(Some(Resolution {
1723 deadline: DEADLINE.into(),
1724 on_deadline: DeadlineEvent::Escalate,
1725 }));
1726 match check_resolution(&e, deadline_unix() + 90) {
1727 ResolutionStatus::Breached {
1728 on_deadline,
1729 seconds_overdue,
1730 } => {
1731 assert_eq!(on_deadline, DeadlineEvent::Escalate);
1732 assert_eq!(seconds_overdue, 90);
1733 }
1734 other => panic!("expected Breached, got {other:?}"),
1735 }
1736 }
1737
1738 #[test]
1739 fn unresolved_inside_its_window_is_pending() {
1740 let e = open_effect(Some(Resolution {
1741 deadline: DEADLINE.into(),
1742 on_deadline: DeadlineEvent::Timeout,
1743 }));
1744 match check_resolution(&e, deadline_unix() - 60) {
1745 ResolutionStatus::Pending { seconds_remaining } => {
1746 assert_eq!(seconds_remaining, 60)
1747 }
1748 other => panic!("expected Pending, got {other:?}"),
1749 }
1750 }
1751
1752 #[test]
1753 fn a_resolved_effect_cannot_breach() {
1754 let e = Effect {
1756 finality: Some(EffectFinality::Finalized),
1757 resolution: Some(Resolution {
1758 deadline: "2026-07-20T11:00:00Z".into(),
1759 on_deadline: DeadlineEvent::Tombstone,
1760 }),
1761 ..Default::default()
1762 };
1763 assert_eq!(
1764 check_resolution(&e, 4_000_000_000),
1765 ResolutionStatus::Resolved
1766 );
1767 }
1768
1769 #[test]
1770 fn unparseable_deadline_fails_toward_unknown() {
1771 let e = open_effect(Some(Resolution {
1774 deadline: "whenever".into(),
1775 on_deadline: DeadlineEvent::Timeout,
1776 }));
1777 assert_eq!(
1778 check_resolution(&e, 1_800_000_000),
1779 ResolutionStatus::BadDeadline
1780 );
1781 }
1782
1783 #[test]
1784 fn missing_finality_is_treated_as_unresolved() {
1785 let e = Effect {
1788 output_hash: Some("sha256:out".into()),
1789 ..Default::default()
1790 };
1791 assert_eq!(
1792 check_resolution(&e, 1_800_000_000),
1793 ResolutionStatus::Indefinite
1794 );
1795 }
1796
1797 #[test]
1798 fn finality_and_resolution_are_omitted_when_absent() {
1799 let json = serde_json::to_string(&Effect {
1801 output_hash: Some("sha256:out".into()),
1802 ..Default::default()
1803 })
1804 .unwrap();
1805 assert!(!json.contains("finality"), "{json}");
1806 assert!(!json.contains("resolution"), "{json}");
1807 }
1808
1809 #[test]
1810 fn verify_effect_honors_verified_backed_by_readback() {
1811 let mut s = good_stmt();
1812 s.effect = Some(Effect {
1813 readback: Some("sha256:observed".into()),
1814 effect_confidence: Some(EffectConfidence::Verified),
1815 ..Default::default()
1816 });
1817 let v = verify_effect(&s, &NoWitnessAuthority);
1818 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1819 assert!(v.is_verified());
1820 }
1821
1822 #[test]
1823 fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1824 let mut s = good_stmt();
1827 s.actor = "agent://worker".into();
1828 s.effect = Some(Effect {
1829 readback: Some("sha256:state".into()),
1830 effect_confidence: Some(EffectConfidence::Verified),
1831 witnesses: vec![Witness {
1832 observer: "agent://auditor".into(),
1833 observation: "sha256:state".into(),
1834 observed_at: Some("2026-07-20T10:00:00Z".into()),
1835 signature: Some("ed25519:sig".into()),
1836 }],
1837 ..Default::default()
1838 });
1839 let v = verify_effect(&s, &TrustingWitnessAuthority);
1840 assert_eq!(v.trusted_witnesses, 1);
1841 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1842
1843 let mut self_witness = s.clone();
1845 if let Some(e) = self_witness.effect.as_mut() {
1846 e.readback = None; e.witnesses[0].observer = "agent://worker".into();
1848 }
1849 let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1850 assert_eq!(v2.trusted_witnesses, 0);
1851 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1852 assert!(v2
1853 .notes
1854 .iter()
1855 .any(|n| n.contains("not independently trusted")));
1856 }
1857
1858 #[test]
1859 fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1860 for c in [
1863 EffectConfidence::Partial,
1864 EffectConfidence::Ambiguous,
1865 EffectConfidence::Unknown,
1866 EffectConfidence::NotVerified,
1867 ] {
1868 let mut s = good_stmt();
1869 s.effect = Some(Effect {
1870 effect_confidence: Some(c),
1871 ..Default::default()
1872 });
1873 let v = verify_effect(&s, &NoWitnessAuthority);
1874 assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1875 }
1876 }
1877
1878 #[test]
1879 fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1880 let s = good_stmt();
1882 assert!(s.effect.is_none());
1883 let v = verify_effect(&s, &NoWitnessAuthority);
1884 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1885 assert_eq!(v.claimed_confidence, None);
1886 assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1887
1888 let mut s2 = good_stmt();
1890 s2.effect = Some(Effect {
1891 output_hash: Some("sha256:out".into()),
1892 ..Default::default()
1893 });
1894 let v2 = verify_effect(&s2, &NoWitnessAuthority);
1895 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1896 assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1897 }
1898
1899 #[test]
1900 fn witness_does_not_inflate_evidence_ceiling() {
1901 let signed_witness = Witness {
1906 observer: "agent://auditor".into(),
1907 observation: "sha256:observed".into(),
1908 observed_at: Some("2026-07-20T10:00:00Z".into()),
1909 signature: Some("ed25519:sig".into()),
1910 };
1911 let e = Effect {
1912 witnesses: vec![signed_witness.clone()],
1913 effect_confidence: Some(EffectConfidence::Verified),
1914 ..Default::default()
1915 };
1916 assert!(!e.has_independent_evidence());
1917 assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1918 assert!(signed_witness.is_signed());
1921 assert_eq!(e.signed_witnesses().count(), 1);
1922
1923 let unsigned = Effect {
1925 witnesses: vec![Witness {
1926 observer: "agent://auditor".into(),
1927 observation: "sha256:observed".into(),
1928 ..Default::default()
1929 }],
1930 ..Default::default()
1931 };
1932 assert_eq!(unsigned.signed_witnesses().count(), 0);
1933 }
1934
1935 #[test]
1936 fn witnesses_serialize_and_omit_when_empty() {
1937 let empty = Effect::default();
1938 assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1939
1940 let e = Effect {
1941 witnesses: vec![Witness {
1942 observer: "key_9f2c".into(),
1943 observation: "sha256:obs".into(),
1944 observed_at: None,
1945 signature: Some("ed25519:sig".into()),
1946 }],
1947 ..Default::default()
1948 };
1949 let j = serde_json::to_string(&e).unwrap();
1950 assert!(j.contains("\"witnesses\":[{"), "{j}");
1951 assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1952 assert!(!j.contains("observed_at"), "{j}");
1954 let back: Effect = serde_json::from_str(&j).unwrap();
1955 assert_eq!(back.witnesses.len(), 1);
1956 assert!(back.witnesses[0].is_signed());
1957 }
1958
1959 #[test]
1960 fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1961 assert!(RuntimeIdentity::default().is_unbound());
1962
1963 let with_model = RuntimeIdentity {
1965 model: Some("claude-opus-4-8".into()),
1966 ..Default::default()
1967 };
1968 assert!(!with_model.is_unbound());
1969
1970 let with_prompt = RuntimeIdentity {
1971 system_prompt_hash: Some("sha256:sys".into()),
1972 ..Default::default()
1973 };
1974 assert!(!with_prompt.is_unbound());
1975 }
1976
1977 #[test]
1978 fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1979 let rt = RuntimeIdentity {
1980 provider: Some("anthropic".into()),
1981 model: Some("claude-opus-4-8".into()),
1982 tool_schema_hash: Some("sha256:tools".into()),
1983 system_prompt_hash: None,
1984 };
1985 let j = serde_json::to_string(&rt).unwrap();
1986 assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1987 assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1988 assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1989 assert!(!j.contains("system_prompt_hash"), "{j}");
1991
1992 let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
1994 assert_eq!(empty, "{}");
1995 let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
1996 assert!(back.is_unbound());
1997 }
1998
1999 #[test]
2000 fn runtime_is_omitted_from_statement_when_absent() {
2001 let s = good_stmt();
2004 assert!(s.runtime.is_none());
2005 let j = serde_json::to_string(&s).unwrap();
2006 assert!(!j.contains("runtime"), "{j}");
2007
2008 let mut with_rt = good_stmt();
2010 with_rt.runtime = Some(RuntimeIdentity {
2011 model: Some("claude-opus-4-8".into()),
2012 ..Default::default()
2013 });
2014 let j2 = serde_json::to_string(&with_rt).unwrap();
2015 assert!(j2.contains("\"runtime\""), "{j2}");
2016 let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
2017 assert_eq!(
2018 back.runtime.unwrap().model.as_deref(),
2019 Some("claude-opus-4-8")
2020 );
2021 }
2022
2023 fn base_mandate() -> Mandate {
2024 Mandate {
2025 grant_id: "grant_9c2f".into(),
2026 grantor: "key_parent".into(),
2027 grantee: Some("key_holder".into()),
2031 issuer_sig: None,
2032 objective_hash: Some("sha256:abc".into()),
2033 scope: vec!["payments.charge".into()],
2034 audience: "acme-payments-api".into(),
2035 parent_request_id: Some("req_7d3e".into()),
2036 delegation_depth: 2,
2037 issued_at: "2026-07-11T19:50:00Z".into(),
2038 expiry: "2026-07-11T20:50:00Z".into(),
2039 max_delegation: 3,
2040 revocation: Revocation {
2041 path: "hub://acme/revocations".into(),
2042 revoked_at: None,
2043 },
2044 chain: Vec::new(),
2045 }
2046 }
2047
2048 fn good_stmt() -> ActionStatementV2 {
2051 let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
2052 s.timestamp = "2026-07-11T19:53:09Z".into();
2053 s.audience = Some("acme-payments-api".into());
2054 s
2055 }
2056
2057 struct StaticRevocation(RevocationStatus);
2058 impl RevocationSource for StaticRevocation {
2059 fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
2060 self.0.clone()
2061 }
2062 }
2063
2064 #[test]
2067 fn scope_exact_and_glob() {
2068 assert!(action_in_scope(
2069 "payments.charge",
2070 &["payments.charge".into()]
2071 ));
2072 assert!(action_in_scope("payments.charge", &["payments.*".into()]));
2073 assert!(action_in_scope("payments", &["payments.*".into()]));
2074 assert!(!action_in_scope(
2075 "payments.refund",
2076 &["payments.charge".into()]
2077 ));
2078 assert!(!action_in_scope("email.send", &["payments.*".into()]));
2079 assert!(!action_in_scope("anything", &["*".into()]));
2081 assert!(action_in_scope("*", &["*".into()]));
2082 }
2083
2084 #[test]
2085 fn empty_scope_authorizes_nothing() {
2086 let mut s = good_stmt();
2087 s.mandate.scope = vec![];
2088 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2089 MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
2090 v => panic!("empty scope must fail, got {v:?}"),
2091 }
2092 }
2093
2094 #[test]
2095 fn action_out_of_scope_fails() {
2096 let mut s = good_stmt();
2097 s.action = "payments.refund".into();
2098 assert!(matches!(
2099 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2100 MandateVerdict::Fail(_)
2101 ));
2102 }
2103
2104 #[test]
2107 fn audience_match_passes_layer() {
2108 let s = good_stmt();
2109 assert_eq!(
2110 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2111 MandateVerdict::Pass
2112 );
2113 }
2114
2115 #[test]
2116 fn audience_mismatch_fails() {
2117 let mut s = good_stmt();
2118 s.audience = Some("evil-api".into());
2119 assert!(matches!(
2120 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2121 MandateVerdict::Fail(_)
2122 ));
2123 }
2124
2125 #[test]
2126 fn missing_action_audience_is_unverified_not_pass() {
2127 let mut s = good_stmt();
2128 s.audience = None;
2129 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2130 MandateVerdict::Unverified(rs) => {
2131 assert!(rs.iter().any(|r| r.contains("recorded no audience")))
2132 }
2133 v => panic!("missing audience must be Unverified, got {v:?}"),
2134 }
2135 }
2136
2137 #[test]
2138 fn empty_mandate_audience_fails() {
2139 let mut s = good_stmt();
2140 s.mandate.audience = "".into();
2141 assert!(matches!(
2142 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2143 MandateVerdict::Fail(_)
2144 ));
2145 }
2146
2147 #[test]
2150 fn signed_before_issued_fails() {
2151 let mut s = good_stmt();
2152 s.timestamp = "2026-07-11T19:49:59Z".into(); assert!(matches!(
2154 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2155 MandateVerdict::Fail(_)
2156 ));
2157 }
2158
2159 #[test]
2160 fn signed_at_expiry_fails() {
2161 let mut s = good_stmt();
2162 s.timestamp = "2026-07-11T20:50:00Z".into(); assert!(matches!(
2164 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2165 MandateVerdict::Fail(_)
2166 ));
2167 }
2168
2169 #[test]
2170 fn signed_within_window_passes() {
2171 let s = good_stmt(); assert_eq!(
2173 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2174 MandateVerdict::Pass
2175 );
2176 }
2177
2178 #[test]
2179 fn malformed_timestamp_fails_closed() {
2180 let mut s = good_stmt();
2181 s.timestamp = "not-a-timestamp".into();
2182 assert!(matches!(
2183 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2184 MandateVerdict::Fail(_)
2185 ));
2186 }
2187
2188 #[test]
2191 fn revoked_after_signing_still_passes() {
2192 let s = good_stmt();
2195 let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
2196 assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
2197 }
2198
2199 #[test]
2200 fn revoked_before_signing_fails() {
2201 let s = good_stmt(); let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
2203 assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
2204 }
2205
2206 #[test]
2207 fn revocation_unknown_is_unverified() {
2208 let s = good_stmt();
2209 match verify_mandate(&s, &NoRevocationSource) {
2210 MandateVerdict::Unverified(rs) => {
2211 assert!(rs
2212 .iter()
2213 .any(|r| r.contains("revocation could not be checked")))
2214 }
2215 v => panic!("no revocation source must be Unverified, got {v:?}"),
2216 }
2217 }
2218
2219 #[test]
2220 fn fail_takes_precedence_over_unverified() {
2221 let mut s = good_stmt();
2224 s.action = "payments.refund".into();
2225 assert!(matches!(
2226 verify_mandate(&s, &NoRevocationSource),
2227 MandateVerdict::Fail(_)
2228 ));
2229 }
2230
2231 #[test]
2232 fn wrong_type_fails() {
2233 let mut s = good_stmt();
2234 s.type_ = "treeship/action/v1".into();
2235 assert!(matches!(
2236 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2237 MandateVerdict::Fail(_)
2238 ));
2239 }
2240
2241 #[test]
2244 fn mandate_is_bound_into_signature() {
2245 let signer = Ed25519Signer::generate("key_test").unwrap();
2246 let pt = payload_type_v2("action");
2247
2248 let a = good_stmt();
2249 let mut b = good_stmt();
2250 b.mandate.scope = vec!["payments.*".into()]; let ra = sign(&pt, &a, &signer).unwrap();
2253 let rb = sign(&pt, &b, &signer).unwrap();
2254 assert_ne!(
2255 ra.artifact_id, rb.artifact_id,
2256 "changing mandate.scope must change the signed artifact id"
2257 );
2258 }
2259
2260 #[test]
2261 fn v2_sign_verify_roundtrip() {
2262 let signer = Ed25519Signer::generate("key_test").unwrap();
2263 let verifier = EnvVerifier::from_signer(&signer);
2264 let pt = payload_type_v2("action");
2265
2266 let mut s = good_stmt();
2267 s.effect = Some(Effect {
2268 output_hash: Some("sha256:out".into()),
2269 readback: Some("sha256:observed".into()),
2270 bytes_moved: Some(1_048_576),
2271 cost: Some(Cost {
2272 unit: "usd_micros".into(),
2273 amount: 4200,
2274 }),
2275 side_effects: vec!["db:users.update".into()],
2276 ..Default::default()
2277 });
2278
2279 let signed = sign(&pt, &s, &signer).unwrap();
2280 verifier.verify(&signed.envelope).unwrap();
2281
2282 let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
2283 assert_eq!(decoded.type_, TYPE_ACTION_V2);
2284 assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
2285 assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
2286 }
2287
2288 #[test]
2289 fn v2_payload_type_differs_from_v1() {
2290 assert_eq!(
2291 payload_type_v2("action"),
2292 "application/vnd.treeship.action.v2+json"
2293 );
2294 assert_ne!(
2295 payload_type_v2("action"),
2296 super::super::payload_type("action")
2297 );
2298 }
2299
2300 #[test]
2303 fn a_bearer_mandate_is_reported_not_passed() {
2304 let mut s = good_stmt();
2308 s.mandate.grantee = None;
2309 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2310 MandateVerdict::Unverified(r) => assert!(
2311 r.iter().any(|x| x.contains("bearer")),
2312 "the reason must name it: {r:?}"
2313 ),
2314 other => panic!("bearer must not pass silently, got {other:?}"),
2315 }
2316 }
2317
2318 #[test]
2319 fn a_bound_mandate_clears_the_holder_layer() {
2320 let s = good_stmt();
2321 assert_eq!(
2322 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2323 MandateVerdict::Pass
2324 );
2325 }
2326
2327 #[test]
2328 fn exercisable_by_is_exact_and_bearer_admits_everyone() {
2329 let mut g = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2330 g.grantee = Some("holder-key".into());
2331 assert!(g.binds_holder());
2332 assert!(g.exercisable_by("holder-key"));
2333 assert!(!g.exercisable_by("someone-else"));
2334
2335 g.grantee = None;
2338 assert!(!g.binds_holder());
2339 assert!(g.exercisable_by("anyone-at-all"));
2340 }
2341
2342 #[test]
2343 fn grantee_is_covered_by_the_signed_bytes() {
2344 let mut a = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2347 let mut b = a.clone();
2348 a.grantee = Some("alice".into());
2349 b.grantee = Some("bob".into());
2350 assert_ne!(a.canonical_for_signing(), b.canonical_for_signing());
2351 assert_ne!(a.derive_grant_id(), b.derive_grant_id());
2352 }
2353
2354 fn grant(
2357 id: &str,
2358 grantor: &str,
2359 scope: &[&str],
2360 depth: u32,
2361 expiry: &str,
2362 max_deleg: u32,
2363 ) -> Grant {
2364 Grant {
2365 grant_id: id.into(),
2366 grantor: grantor.into(),
2367 grantee: None,
2368 issuer_sig: None,
2369 scope: scope.iter().map(|s| (*s).into()).collect(),
2370 audience: "acme-payments-api".into(),
2371 parent_request_id: None,
2372 parent_grant_id: None,
2373 delegation_depth: depth,
2374 issued_at: "2026-07-11T19:00:00Z".into(),
2375 expiry: expiry.into(),
2376 max_delegation: max_deleg,
2377 objective_hash: None,
2378 }
2379 }
2380
2381 #[test]
2382 fn grant_sign_verify_roundtrip_and_tamper() {
2383 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2384 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2385 let mut g = grant(
2386 "grant_root",
2387 &grantor,
2388 &["payments.*"],
2389 0,
2390 "2026-07-11T21:00:00Z",
2391 3,
2392 );
2393 g.grant_id = g.derive_grant_id();
2396
2397 let sig = g.sign_canonical(&signer).unwrap();
2398 assert!(g.verify_canonical(&sig));
2399
2400 g.scope.push("email.*".into());
2402 assert!(!g.verify_canonical(&sig));
2403 }
2404
2405 #[test]
2406 fn grant_verify_rejects_wrong_key() {
2407 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2408 let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
2409 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2410 let g = grant(
2411 "grant_root",
2412 &grantor,
2413 &["payments.*"],
2414 0,
2415 "2026-07-11T21:00:00Z",
2416 3,
2417 );
2418 let sig = g.sign_canonical(&attacker).unwrap();
2419 assert!(!g.verify_canonical(&sig));
2420 }
2421
2422 #[test]
2423 fn valid_attenuating_chain_ok() {
2424 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2425 let child = grant(
2426 "g1",
2427 "k",
2428 &["payments.charge"],
2429 1,
2430 "2026-07-11T20:30:00Z",
2431 3,
2432 );
2433 assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
2434 }
2435
2436 #[test]
2437 fn scope_widening_rejected() {
2438 let root = grant(
2439 "g0",
2440 "k",
2441 &["payments.charge"],
2442 0,
2443 "2026-07-11T21:00:00Z",
2444 3,
2445 );
2446 let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
2447 assert_eq!(
2448 verify_grant_chain(&[root, child]),
2449 Err(GrantChainError::ScopeWidened { parent: 0 })
2450 );
2451 }
2452
2453 #[test]
2454 fn expiry_widening_rejected() {
2455 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2456 let child = grant(
2457 "g1",
2458 "k",
2459 &["payments.charge"],
2460 1,
2461 "2026-07-11T22:00:00Z",
2462 3,
2463 );
2464 assert_eq!(
2465 verify_grant_chain(&[root, child]),
2466 Err(GrantChainError::ExpiryWidened { parent: 0 })
2467 );
2468 }
2469
2470 #[test]
2471 fn depth_not_incremented_rejected() {
2472 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2473 let child = grant(
2474 "g1",
2475 "k",
2476 &["payments.charge"],
2477 2,
2478 "2026-07-11T21:00:00Z",
2479 3,
2480 );
2481 assert_eq!(
2482 verify_grant_chain(&[root, child]),
2483 Err(GrantChainError::DepthNotIncremented { parent: 0 })
2484 );
2485 }
2486
2487 #[test]
2488 fn depth_exceeds_max_rejected() {
2489 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
2490 let child = grant(
2491 "g1",
2492 "k",
2493 &["payments.charge"],
2494 1,
2495 "2026-07-11T21:00:00Z",
2496 0,
2497 );
2498 assert_eq!(
2499 verify_grant_chain(&[root, child]),
2500 Err(GrantChainError::DepthExceedsMax { parent: 0 })
2501 );
2502 }
2503
2504 #[test]
2505 fn audience_change_rejected() {
2506 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2507 let mut child = grant(
2508 "g1",
2509 "k",
2510 &["payments.charge"],
2511 1,
2512 "2026-07-11T21:00:00Z",
2513 3,
2514 );
2515 child.audience = "other-api".into();
2516 assert_eq!(
2517 verify_grant_chain(&[root, child]),
2518 Err(GrantChainError::AudienceChanged { parent: 0 })
2519 );
2520 }
2521
2522 #[test]
2523 fn empty_chain_rejected() {
2524 assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
2525 }
2526
2527 #[test]
2528 fn bad_timestamp_in_chain_rejected() {
2529 let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2530 root.expiry = "nope".into();
2531 assert_eq!(
2532 verify_grant_chain(&[root]),
2533 Err(GrantChainError::BadTimestamp { index: 0 })
2534 );
2535 }
2536
2537 #[test]
2538 fn single_grant_chain_ok() {
2539 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2540 assert_eq!(verify_grant_chain(&[root]), Ok(()));
2541 }
2542
2543 fn mk_grant(
2547 signer: &Ed25519Signer,
2548 grantor_pk: &str,
2549 scope: Vec<&str>,
2550 depth: u32,
2551 parent: Option<&str>,
2552 ) -> Grant {
2553 let mut g = Grant {
2554 grant_id: String::new(),
2555 grantor: grantor_pk.to_string(),
2556 grantee: None,
2557 issuer_sig: None,
2558 scope: scope.into_iter().map(String::from).collect(),
2559 audience: "acme".into(),
2560 parent_request_id: None,
2561 parent_grant_id: parent.map(String::from),
2562 delegation_depth: depth,
2563 issued_at: "2026-07-20T10:00:00Z".into(),
2564 expiry: "2026-07-20T11:00:00Z".into(),
2565 max_delegation: 3,
2566 objective_hash: None,
2567 };
2568 g.grant_id = g.derive_grant_id();
2569 g.issuer_sig = Some(g.sign_canonical(signer).unwrap());
2570 g
2571 }
2572
2573 fn chain_fixture() -> (Grant, Grant, Ed25519Signer) {
2574 let signer = Ed25519Signer::generate("issuer").unwrap();
2575 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2576 let root = mk_grant(&signer, &pk, vec!["payments.*"], 0, None);
2577 let leaf = mk_grant(
2578 &signer,
2579 &pk,
2580 vec!["payments.charge"],
2581 1,
2582 Some(&root.grant_id),
2583 );
2584 (root, leaf, signer)
2585 }
2586
2587 fn mandate_with(leaf: &Grant, chain: Vec<Grant>) -> Mandate {
2588 let mut m = base_mandate();
2589 m.grant_id = leaf.grant_id.clone();
2590 m.chain = chain;
2591 m
2592 }
2593
2594 #[test]
2595 fn grant_id_is_content_derived_and_stable() {
2596 let (root, _, _) = chain_fixture();
2597 assert!(root.grant_id.starts_with("grn_"));
2598 assert_eq!(root.grant_id, root.derive_grant_id());
2599 let mut altered = root.clone();
2601 altered.scope = vec!["payments.refund".into()];
2602 assert_ne!(altered.derive_grant_id(), root.grant_id);
2603 }
2604
2605 #[test]
2606 fn hand_chosen_id_fails_verification() {
2607 let (mut root, _, _) = chain_fixture();
2608 let sig = root.issuer_sig.clone().unwrap();
2609 root.grant_id = "grn_deadbeefdeadbeef".into();
2610 assert!(
2611 !root.verify_canonical(&sig),
2612 "an id that was chosen rather than computed must not verify"
2613 );
2614 }
2615
2616 #[test]
2617 fn resolves_root_first_regardless_of_carrier_order() {
2618 let (root, leaf, _) = chain_fixture();
2619 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2621 let resolved = resolve_grant_chain(&m).expect("resolves");
2622 assert_eq!(resolved.len(), 2);
2623 assert_eq!(resolved[0].grant_id, root.grant_id, "root must come first");
2624 assert_eq!(resolved[1].grant_id, leaf.grant_id);
2625 }
2626
2627 #[test]
2628 fn truncated_chain_is_rejected() {
2629 let (_, leaf, _) = chain_fixture();
2630 let m = mandate_with(&leaf, vec![leaf.clone()]);
2633 match resolve_grant_chain(&m) {
2634 Err(ChainResolveError::AncestorMissing { .. }) => {}
2635 other => panic!("truncation must be caught, got {other:?}"),
2636 }
2637 }
2638
2639 #[test]
2640 fn spliced_decoy_grant_is_rejected() {
2641 let (root, leaf, signer) = chain_fixture();
2642 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2643 let decoy = mk_grant(&signer, &pk, vec!["email.send"], 0, None);
2647 assert_ne!(decoy.grant_id, root.grant_id);
2648 let m = mandate_with(&leaf, vec![root.clone(), leaf.clone(), decoy]);
2649 match resolve_grant_chain(&m) {
2650 Err(ChainResolveError::UnreachableExtras { count }) => assert_eq!(count, 1),
2651 other => panic!("unreachable extras must be refused, got {other:?}"),
2652 }
2653 }
2654
2655 #[test]
2656 fn unsigned_ancestor_is_rejected() {
2657 let (mut root, leaf, _) = chain_fixture();
2658 root.issuer_sig = None;
2659 let m = mandate_with(&leaf, vec![root, leaf.clone()]);
2660 assert!(matches!(
2661 resolve_grant_chain(&m),
2662 Err(ChainResolveError::Unsigned { .. })
2663 ));
2664 }
2665
2666 #[test]
2667 fn resolved_chain_feeds_attenuation_check() {
2668 let (root, leaf, _) = chain_fixture();
2671 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2672 let resolved = resolve_grant_chain(&m).expect("resolves");
2673 assert!(
2674 verify_grant_chain(&resolved).is_ok(),
2675 "narrowing scope at depth+1 must satisfy attenuation"
2676 );
2677 }
2678
2679 #[test]
2680 fn leaf_not_in_chain_is_rejected() {
2681 let (root, leaf, _) = chain_fixture();
2685 let mut m = mandate_with(&leaf, vec![root.clone()]);
2686 m.grant_id = leaf.grant_id.clone();
2687 match resolve_grant_chain(&m) {
2688 Err(ChainResolveError::LeafMissing { grant_id }) => {
2689 assert_eq!(grant_id, leaf.grant_id);
2690 }
2691 other => panic!("a mandate naming an absent leaf must fail, got {other:?}"),
2692 }
2693 }
2694
2695 #[test]
2696 fn ancestor_signed_by_a_stranger_is_rejected() {
2697 let (root, leaf, _) = chain_fixture();
2701 let stranger = Ed25519Signer::generate("stranger").unwrap();
2702 let mut forged = root.clone();
2703 forged.issuer_sig = Some(forged.sign_canonical(&stranger).unwrap());
2704 assert_eq!(
2705 forged.grant_id, root.grant_id,
2706 "signing with another key must not change the content id"
2707 );
2708
2709 let m = mandate_with(&leaf, vec![forged, leaf.clone()]);
2710 match resolve_grant_chain(&m) {
2711 Err(ChainResolveError::BadSignature { grant_id }) => {
2712 assert_eq!(grant_id, root.grant_id);
2713 }
2714 other => panic!("a grant signed by a non-grantor must fail, got {other:?}"),
2715 }
2716 }
2717
2718 #[test]
2719 fn inconsistent_id_is_caught_before_signature_check() {
2720 let (root, leaf, _) = chain_fixture();
2724 let mut tampered = root.clone();
2725 tampered.grant_id = "grn_0000000000000000".into();
2726 let m = mandate_with(&leaf, vec![tampered, leaf.clone()]);
2727 assert!(matches!(
2728 resolve_grant_chain(&m),
2729 Err(ChainResolveError::InconsistentId { .. })
2730 ));
2731 }
2732 #[test]
2744 fn mandate_cannot_claim_more_than_the_grant_it_names() {
2745 let (root, leaf, _) = chain_fixture();
2746 assert_eq!(leaf.scope, vec!["payments.charge".to_string()]);
2747
2748 let mut m = mandate_with(&leaf, vec![root.clone(), leaf.clone()]);
2749 m.scope = vec!["payments.*".into()];
2750 m.audience = leaf.audience.clone();
2751
2752 let chain = resolve_grant_chain(&m).expect("chain resolves");
2754 assert_eq!(verify_grant_chain(&chain), Ok(()));
2755
2756 let mut stmt = good_stmt();
2757 stmt.action = "payments.refund".into();
2758 stmt.audience = Some(leaf.audience.clone());
2759 stmt.mandate = m;
2760
2761 match verify_mandate(&stmt, &StaticRevocation(RevocationStatus::NotRevoked)) {
2762 MandateVerdict::Fail(reasons) => assert!(
2763 reasons.iter().any(|r| r.contains("exceeds the leaf grant")),
2764 "failed for the wrong reason: {reasons:?}"
2765 ),
2766 other => panic!("an action outside the leaf grant must FAIL, got {other:?}"),
2767 }
2768 }
2769
2770 #[test]
2773 fn a_mandate_within_its_grant_still_passes() {
2774 let (root, leaf, _) = chain_fixture();
2775 let mut m = mandate_with(&leaf, vec![root.clone(), leaf.clone()]);
2776 m.scope = leaf.scope.clone();
2777 m.audience = leaf.audience.clone();
2778
2779 let mut stmt = good_stmt();
2780 stmt.action = "payments.charge".into();
2781 stmt.audience = Some(leaf.audience.clone());
2782 stmt.mandate = m;
2783
2784 assert_eq!(
2785 verify_mandate(&stmt, &StaticRevocation(RevocationStatus::NotRevoked)),
2786 MandateVerdict::Pass
2787 );
2788 }
2789
2790 #[test]
2794 fn objective_cannot_change_or_be_dropped_across_a_delegation() {
2795 let (root, leaf, _) = chain_fixture();
2796
2797 let mut p = root.clone();
2798 p.objective_hash = Some("sha256:task-a".into());
2799
2800 let mut swapped = leaf.clone();
2801 swapped.objective_hash = Some("sha256:task-b".into());
2802 assert_eq!(
2803 verify_grant_chain(&[p.clone(), swapped]),
2804 Err(GrantChainError::ObjectiveChanged { parent: 0 })
2805 );
2806
2807 let mut dropped = leaf.clone();
2808 dropped.objective_hash = None;
2809 assert_eq!(
2810 verify_grant_chain(&[p.clone(), dropped]),
2811 Err(GrantChainError::ObjectiveChanged { parent: 0 })
2812 );
2813
2814 let mut added = leaf.clone();
2816 added.objective_hash = Some("sha256:task-a".into());
2817 assert_eq!(verify_grant_chain(&[root.clone(), added]), Ok(()));
2818
2819 let mut same = leaf.clone();
2821 same.objective_hash = Some("sha256:task-a".into());
2822 assert_eq!(verify_grant_chain(&[p, same]), Ok(()));
2823 }
2824}