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 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub retry: Option<super::Retry>,
556
557 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub idempotency_key: Option<String>,
560}
561
562fn subject_is_empty(s: &SubjectRef) -> bool {
563 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
564}
565
566impl ActionStatementV2 {
567 pub fn new(actor: impl Into<String>, action: impl Into<String>, mandate: Mandate) -> Self {
569 Self {
570 type_: TYPE_ACTION_V2.into(),
571 timestamp: super::unix_to_rfc3339(now_unix()),
572 actor: actor.into(),
573 action: action.into(),
574 audience: None,
575 subject: SubjectRef::default(),
576 parent_id: None,
577 mandate,
578 effect: None,
579 runtime: None,
580 meta: None,
581 retry: None,
582 idempotency_key: None,
583 }
584 }
585}
586
587fn now_unix() -> u64 {
588 use std::time::{SystemTime, UNIX_EPOCH};
589 SystemTime::now()
590 .duration_since(UNIX_EPOCH)
591 .unwrap_or_default()
592 .as_secs()
593}
594
595pub fn action_in_scope(action: &str, scope: &[String]) -> bool {
605 scope.iter().any(|entry| scope_entry_matches(entry, action))
606}
607
608fn scope_entry_matches(entry: &str, action: &str) -> bool {
609 if let Some(prefix) = entry.strip_suffix(".*") {
610 action == prefix || action.starts_with(&format!("{prefix}."))
611 } else {
612 entry == action
613 }
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
622pub enum RevocationStatus {
623 NotRevoked,
625 RevokedAt(String),
627 Unknown(String),
630}
631
632pub trait RevocationSource {
638 fn status(&self, grant_id: &str, path: &str) -> RevocationStatus;
639}
640
641pub struct NoRevocationSource;
643
644impl RevocationSource for NoRevocationSource {
645 fn status(&self, _grant_id: &str, path: &str) -> RevocationStatus {
646 RevocationStatus::Unknown(format!("no revocation source configured for path '{path}'"))
647 }
648}
649
650#[derive(Debug, Clone, PartialEq, Eq)]
660pub enum MandateVerdict {
661 Pass,
662 Unverified(Vec<String>),
663 Fail(Vec<String>),
664}
665
666impl MandateVerdict {
667 pub fn is_pass(&self) -> bool {
668 matches!(self, MandateVerdict::Pass)
669 }
670}
671
672pub fn verify_mandate(
683 stmt: &ActionStatementV2,
684 revocation: &dyn RevocationSource,
685) -> MandateVerdict {
686 let mut fail: Vec<String> = Vec::new();
687 let mut unver: Vec<String> = Vec::new();
688
689 if stmt.type_ != TYPE_ACTION_V2 {
690 return MandateVerdict::Fail(vec![format!(
691 "statement type '{}' is not {TYPE_ACTION_V2}",
692 stmt.type_
693 )]);
694 }
695
696 let m = &stmt.mandate;
697
698 let signed_at = match parse_rfc3339_to_unix(&stmt.timestamp) {
701 Some(t) => t,
702 None => {
703 return MandateVerdict::Fail(vec![format!(
704 "timestamp '{}' is not RFC 3339",
705 stmt.timestamp
706 )])
707 }
708 };
709
710 if m.scope.is_empty() {
712 fail.push("mandate.scope is empty: it authorizes no action".into());
713 } else if !action_in_scope(&stmt.action, &m.scope) {
714 fail.push(format!(
715 "action '{}' is not in mandate scope {:?}",
716 stmt.action, m.scope
717 ));
718 }
719
720 if m.audience.trim().is_empty() {
722 fail.push("mandate.audience is empty: the grant is not bound to an audience".into());
723 } else {
724 match &stmt.audience {
725 Some(a) if a == &m.audience => {}
726 Some(a) => fail.push(format!(
727 "action audience '{a}' does not match mandate audience '{}'",
728 m.audience
729 )),
730 None => unver
731 .push("action recorded no audience; cannot confirm it matched the mandate".into()),
732 }
733 }
734
735 match (
737 parse_rfc3339_to_unix(&m.issued_at),
738 parse_rfc3339_to_unix(&m.expiry),
739 ) {
740 (Some(issued), Some(expiry)) => {
741 if expiry <= issued {
742 fail.push(format!(
743 "mandate expiry '{}' is not after issued_at '{}'",
744 m.expiry, m.issued_at
745 ));
746 }
747 if signed_at < issued {
748 fail.push(format!(
749 "signed_at '{}' is before mandate issued_at '{}'",
750 stmt.timestamp, m.issued_at
751 ));
752 }
753 if signed_at >= expiry {
754 fail.push(format!(
755 "signed_at '{}' is at or after mandate expiry '{}'",
756 stmt.timestamp, m.expiry
757 ));
758 }
759 }
760 _ => fail.push(format!(
761 "mandate issued_at '{}' / expiry '{}' are not both RFC 3339",
762 m.issued_at, m.expiry
763 )),
764 }
765
766 match revocation.status(&m.grant_id, &m.revocation.path) {
769 RevocationStatus::NotRevoked => {}
770 RevocationStatus::RevokedAt(ts) => match parse_rfc3339_to_unix(&ts) {
771 Some(revoked_at) => {
772 if signed_at >= revoked_at {
773 fail.push(format!(
774 "grant was revoked at '{ts}'; signed_at '{}' is not before revocation",
775 stmt.timestamp
776 ));
777 }
778 }
779 None => unver.push(format!("revocation timestamp '{ts}' is not RFC 3339")),
780 },
781 RevocationStatus::Unknown(reason) => {
782 unver.push(format!("revocation could not be checked: {reason}"))
783 }
784 }
785
786 if !m.chain.is_empty() {
808 match resolve_grant_chain(m) {
809 Err(e) => fail.push(format!("grant chain does not resolve: {e}")),
810 Ok(chain) => {
811 if let Err(e) = verify_grant_chain(&chain) {
812 fail.push(format!("grant chain attenuation: {e}"));
813 } else if let Some(leaf) = chain.last() {
814 if !scope_subset(&m.scope, &leaf.scope) {
815 fail.push(format!(
816 "mandate scope {:?} exceeds the leaf grant's scope {:?}: the \
817 mandate claims authority the grantor did not give",
818 m.scope, leaf.scope
819 ));
820 }
821 if m.audience != leaf.audience {
822 fail.push(format!(
823 "mandate audience '{}' does not match the leaf grant's '{}'",
824 m.audience, leaf.audience
825 ));
826 }
827 match (&leaf.objective_hash, &m.objective_hash) {
831 (Some(g), Some(mm)) if g != mm => fail.push(format!(
832 "mandate objective '{mm}' does not match the leaf grant's '{g}'"
833 )),
834 (Some(g), None) => fail.push(format!(
835 "leaf grant is bound to objective '{g}' and the mandate declares \
836 none: dropping the binding removes the constraint"
837 )),
838 _ => {}
839 }
840 }
841 }
842 }
843 }
844
845 if m.grantee.as_deref().unwrap_or("").is_empty() {
855 unver.push(
856 "grant names no grantee (bearer): any holder of the grant could have produced this"
857 .into(),
858 );
859 }
860
861 if !fail.is_empty() {
862 MandateVerdict::Fail(fail)
863 } else if !unver.is_empty() {
864 MandateVerdict::Unverified(unver)
865 } else {
866 MandateVerdict::Pass
867 }
868}
869
870pub trait WitnessAuthority {
883 fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
884}
885
886pub struct NoWitnessAuthority;
889
890impl WitnessAuthority for NoWitnessAuthority {
891 fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
892 false
893 }
894}
895
896#[derive(Debug, Clone, PartialEq, Eq)]
902pub struct EffectVerdict {
903 pub effective_confidence: EffectConfidence,
909 pub claimed_confidence: Option<EffectConfidence>,
912 pub trusted_witnesses: usize,
914 pub notes: Vec<String>,
916 pub effective_finality: Option<EffectFinality>,
922 pub claimed_finality: Option<EffectFinality>,
924}
925
926impl EffectVerdict {
927 pub fn is_verified(&self) -> bool {
929 self.effective_confidence == EffectConfidence::Verified
930 }
931}
932
933pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
948 let effect = match &stmt.effect {
949 Some(e) => e,
950 None => {
951 return EffectVerdict {
952 effective_confidence: EffectConfidence::NotVerified,
953 claimed_confidence: None,
954 trusted_witnesses: 0,
955 notes: vec!["receipt carries no effect block; effect is unverified".into()],
956 effective_finality: None,
957 claimed_finality: None,
958 }
959 }
960 };
961
962 let mut notes: Vec<String> = Vec::new();
963
964 let trusted_witnesses = effect
965 .witnesses
966 .iter()
967 .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
968 .count();
969 let untrusted = effect.witnesses.len() - trusted_witnesses;
970 if untrusted > 0 {
971 notes.push(format!(
972 "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
973 effect.witnesses.len()
974 ));
975 }
976
977 let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
980
981 let claimed = effect.effect_confidence;
982 let effective = match claimed {
983 None => {
984 notes.push("actor recorded no effect_confidence; effect is unverified".into());
985 EffectConfidence::NotVerified
986 }
987 Some(EffectConfidence::Verified) if !has_evidence => {
988 notes.push(
989 "actor claimed Verified but bundled no independent evidence \
990 (no readback, no trusted witness); downgraded to NotVerified"
991 .into(),
992 );
993 EffectConfidence::NotVerified
994 }
995 Some(c) => c,
996 };
997
998 let claimed_finality = effect.finality;
1008 let effective_finality = match claimed_finality {
1009 Some(EffectFinality::Finalized) if !has_evidence => {
1010 notes.push(
1011 "actor claimed the effect Finalized but bundled no independent evidence \
1012 (no readback, no trusted witness); downgraded to Indeterminate"
1013 .into(),
1014 );
1015 Some(EffectFinality::Indeterminate)
1016 }
1017 other => other,
1018 };
1019
1020 EffectVerdict {
1021 effective_confidence: effective,
1022 claimed_confidence: claimed,
1023 trusted_witnesses,
1024 notes,
1025 effective_finality,
1026 claimed_finality,
1027 }
1028}
1029
1030#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1040pub struct Grant {
1041 pub grant_id: String,
1042 pub grantor: String,
1044 #[serde(default)]
1045 pub scope: Vec<String>,
1046 pub audience: String,
1047 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub parent_request_id: Option<String>,
1049 #[serde(default)]
1050 pub delegation_depth: u32,
1051 pub issued_at: String,
1052 pub expiry: String,
1053 #[serde(default)]
1054 pub max_delegation: u32,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub objective_hash: Option<String>,
1057
1058 #[serde(default, skip_serializing_if = "Option::is_none")]
1063 pub issuer_sig: Option<String>,
1064
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub parent_grant_id: Option<String>,
1071
1072 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub grantee: Option<String>,
1086}
1087
1088impl Grant {
1089 pub fn canonical_for_signing(&self) -> String {
1094 let scope_digest = canonical_json_digest(&self.scope);
1095 format!(
1106 "v3|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
1107 self.grantor,
1108 scope_digest,
1109 self.audience,
1110 self.parent_request_id.as_deref().unwrap_or(""),
1111 self.parent_grant_id.as_deref().unwrap_or(""),
1112 self.grantee.as_deref().unwrap_or(""),
1113 self.delegation_depth,
1114 self.issued_at,
1115 self.expiry,
1116 self.max_delegation,
1117 self.objective_hash.as_deref().unwrap_or(""),
1118 )
1119 }
1120
1121 pub fn binds_holder(&self) -> bool {
1128 self.grantee.as_deref().is_some_and(|g| !g.is_empty())
1129 }
1130
1131 pub fn exercisable_by(&self, holder_pubkey: &str) -> bool {
1135 match self.grantee.as_deref() {
1136 Some(g) if !g.is_empty() => g == holder_pubkey,
1137 _ => true,
1138 }
1139 }
1140
1141 pub fn derive_grant_id(&self) -> String {
1148 let digest = Sha256::digest(self.canonical_for_signing().as_bytes());
1149 format!("grn_{}", hex::encode(&digest[..8]))
1150 }
1151
1152 pub fn id_is_consistent(&self) -> bool {
1156 self.grant_id == self.derive_grant_id()
1157 }
1158
1159 pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
1163 let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
1164 Ok(URL_SAFE_NO_PAD.encode(sig))
1165 }
1166
1167 pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
1172 if !self.id_is_consistent() {
1176 return false;
1177 }
1178 let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
1179 Ok(b) if b.len() == 32 => b,
1180 _ => return false,
1181 };
1182 let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
1183 Ok(b) if b.len() == 64 => b,
1184 _ => return false,
1185 };
1186 let mut pk = [0u8; 32];
1187 pk.copy_from_slice(&pk_bytes);
1188 let mut sig = [0u8; 64];
1189 sig.copy_from_slice(&sig_bytes);
1190 let vk = match VerifyingKey::from_bytes(&pk) {
1191 Ok(k) => k,
1192 Err(_) => return false,
1193 };
1194 vk.verify_strict(
1195 self.canonical_for_signing().as_bytes(),
1196 &Signature::from_bytes(&sig),
1197 )
1198 .is_ok()
1199 }
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Eq)]
1204pub enum ChainResolveError {
1205 InconsistentId { grant_id: String },
1207 LeafMissing { grant_id: String },
1209 AncestorMissing { parent_grant_id: String },
1211 Cycle { grant_id: String },
1213 UnreachableExtras { count: usize },
1217 Unsigned { grant_id: String },
1219 BadSignature { grant_id: String },
1221}
1222
1223impl std::fmt::Display for ChainResolveError {
1224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1228 match self {
1229 Self::InconsistentId { grant_id } => {
1230 write!(
1231 f,
1232 "grant {grant_id} declares an id that does not match its content"
1233 )
1234 }
1235 Self::LeafMissing { grant_id } => {
1236 write!(
1237 f,
1238 "the mandate names grant {grant_id}, which is not in the carried chain"
1239 )
1240 }
1241 Self::AncestorMissing { parent_grant_id } => {
1242 write!(
1243 f,
1244 "parent grant {parent_grant_id} is missing from the chain"
1245 )
1246 }
1247 Self::Cycle { grant_id } => {
1248 write!(
1249 f,
1250 "parent links revisit grant {grant_id}: the chain is a cycle"
1251 )
1252 }
1253 Self::UnreachableExtras { count } => {
1254 write!(
1255 f,
1256 "{count} carried grant(s) are not reachable from the mandate"
1257 )
1258 }
1259 Self::Unsigned { grant_id } => {
1260 write!(f, "grant {grant_id} carries no issuer signature")
1261 }
1262 Self::BadSignature { grant_id } => {
1263 write!(f, "grant {grant_id} has a signature that does not verify")
1264 }
1265 }
1266 }
1267}
1268
1269impl std::error::Error for ChainResolveError {}
1270
1271pub fn resolve_grant_chain(mandate: &Mandate) -> Result<Vec<Grant>, ChainResolveError> {
1284 use std::collections::{HashMap, HashSet};
1285
1286 let mut by_id: HashMap<String, &Grant> = HashMap::new();
1288 for g in &mandate.chain {
1289 if !g.id_is_consistent() {
1290 return Err(ChainResolveError::InconsistentId {
1291 grant_id: g.grant_id.clone(),
1292 });
1293 }
1294 let sig = match g.issuer_sig.as_deref() {
1295 Some(s) if !s.is_empty() => s,
1296 _ => {
1297 return Err(ChainResolveError::Unsigned {
1298 grant_id: g.grant_id.clone(),
1299 })
1300 }
1301 };
1302 if !g.verify_canonical(sig) {
1303 return Err(ChainResolveError::BadSignature {
1304 grant_id: g.grant_id.clone(),
1305 });
1306 }
1307 by_id.insert(g.grant_id.clone(), g);
1308 }
1309
1310 let mut leaf_first: Vec<Grant> = Vec::new();
1312 let mut seen: HashSet<String> = HashSet::new();
1313 let mut cursor = Some(mandate.grant_id.clone());
1314
1315 while let Some(id) = cursor {
1316 if !seen.insert(id.clone()) {
1317 return Err(ChainResolveError::Cycle { grant_id: id });
1318 }
1319 let g = match by_id.get(&id) {
1320 Some(g) => *g,
1321 None => {
1322 return Err(if leaf_first.is_empty() {
1323 ChainResolveError::LeafMissing { grant_id: id }
1324 } else {
1325 ChainResolveError::AncestorMissing {
1326 parent_grant_id: id,
1327 }
1328 })
1329 }
1330 };
1331 leaf_first.push(g.clone());
1332 cursor = g.parent_grant_id.clone();
1333 }
1334
1335 if seen.len() != by_id.len() {
1337 return Err(ChainResolveError::UnreachableExtras {
1338 count: by_id.len() - seen.len(),
1339 });
1340 }
1341
1342 leaf_first.reverse(); Ok(leaf_first)
1344}
1345
1346#[derive(Debug, Clone, PartialEq, Eq)]
1348pub enum GrantChainError {
1349 Empty,
1351 BadTimestamp { index: usize },
1353 ScopeWidened { parent: usize },
1355 ExpiryWidened { parent: usize },
1357 DepthNotIncremented { parent: usize },
1359 DepthExceedsMax { parent: usize },
1361 AudienceChanged { parent: usize },
1363 ObjectiveChanged { parent: usize },
1375}
1376
1377impl std::fmt::Display for GrantChainError {
1378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1382 match self {
1383 Self::Empty => write!(f, "the chain is empty"),
1384 Self::BadTimestamp { index } => {
1385 write!(
1386 f,
1387 "grant at hop {index} has an unparseable issued_at/expiry"
1388 )
1389 }
1390 Self::ScopeWidened { parent } => {
1391 write!(f, "scope widens at hop {}->{}", parent, parent + 1)
1392 }
1393 Self::ExpiryWidened { parent } => {
1394 write!(
1395 f,
1396 "expiry extends past the parent at hop {}->{}",
1397 parent,
1398 parent + 1
1399 )
1400 }
1401 Self::DepthNotIncremented { parent } => {
1402 write!(
1403 f,
1404 "delegation depth does not increment by one at hop {}->{}",
1405 parent,
1406 parent + 1
1407 )
1408 }
1409 Self::DepthExceedsMax { parent } => {
1410 write!(
1411 f,
1412 "delegation depth exceeds the parent's max_delegation at hop {}->{}",
1413 parent,
1414 parent + 1
1415 )
1416 }
1417 Self::ObjectiveChanged { parent } => write!(
1418 f,
1419 "objective changed or was dropped at hop {}->{}: authority granted for one \
1420 task cannot be spent on another",
1421 parent,
1422 parent + 1
1423 ),
1424 Self::AudienceChanged { parent } => {
1425 write!(f, "audience changes at hop {}->{}", parent, parent + 1)
1426 }
1427 }
1428 }
1429}
1430
1431impl std::error::Error for GrantChainError {}
1432
1433pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
1443 if chain.is_empty() {
1444 return Err(GrantChainError::Empty);
1445 }
1446
1447 for (i, g) in chain.iter().enumerate() {
1450 if parse_rfc3339_to_unix(&g.issued_at).is_none()
1451 || parse_rfc3339_to_unix(&g.expiry).is_none()
1452 {
1453 return Err(GrantChainError::BadTimestamp { index: i });
1454 }
1455 }
1456
1457 for (i, pair) in chain.windows(2).enumerate() {
1458 let parent = &pair[0];
1459 let child = &pair[1];
1460
1461 if !scope_subset(&child.scope, &parent.scope) {
1462 return Err(GrantChainError::ScopeWidened { parent: i });
1463 }
1464
1465 let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
1467 let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
1468 if child_expiry > parent_expiry {
1469 return Err(GrantChainError::ExpiryWidened { parent: i });
1470 }
1471
1472 if child.delegation_depth != parent.delegation_depth + 1 {
1473 return Err(GrantChainError::DepthNotIncremented { parent: i });
1474 }
1475 if child.delegation_depth > parent.max_delegation {
1476 return Err(GrantChainError::DepthExceedsMax { parent: i });
1477 }
1478
1479 if child.audience != parent.audience {
1480 return Err(GrantChainError::AudienceChanged { parent: i });
1481 }
1482
1483 match (&parent.objective_hash, &child.objective_hash) {
1488 (Some(p), Some(c)) if p != c => {
1489 return Err(GrantChainError::ObjectiveChanged { parent: i });
1490 }
1491 (Some(_), None) => {
1492 return Err(GrantChainError::ObjectiveChanged { parent: i });
1493 }
1494 _ => {}
1495 }
1496 }
1497
1498 Ok(())
1499}
1500
1501fn scope_subset(child: &[String], parent: &[String]) -> bool {
1505 child
1506 .iter()
1507 .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
1508}
1509
1510fn scope_entry_covers(parent: &str, child: &str) -> bool {
1511 if parent == child {
1512 return true;
1513 }
1514 if let Some(parent_prefix) = parent.strip_suffix(".*") {
1515 let child_core = child.strip_suffix(".*").unwrap_or(child);
1518 child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
1519 } else {
1520 false
1521 }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526 use super::*;
1527 use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
1528
1529 #[test]
1530 fn effect_confidence_ceiling_gates_on_independent_evidence() {
1531 let with_evidence = Effect {
1533 readback: Some("sha256:observed".into()),
1534 effect_confidence: Some(EffectConfidence::Verified),
1535 ..Default::default()
1536 };
1537 assert!(with_evidence.has_independent_evidence());
1538 assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
1539
1540 let claim_only = Effect {
1543 output_hash: Some("sha256:out".into()),
1544 effect_confidence: Some(EffectConfidence::Verified),
1545 ..Default::default()
1546 };
1547 assert!(!claim_only.has_independent_evidence());
1548 assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
1549
1550 let honest_downgrade = Effect {
1552 effect_confidence: Some(EffectConfidence::Unknown),
1553 ..Default::default()
1554 };
1555 assert_eq!(
1556 honest_downgrade.evidence_ceiling(),
1557 EffectConfidence::NotVerified
1558 );
1559 }
1560
1561 #[test]
1562 fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
1563 let e = Effect {
1564 effect_confidence: Some(EffectConfidence::NotVerified),
1565 ..Default::default()
1566 };
1567 let j = serde_json::to_string(&e).unwrap();
1568 assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
1569
1570 let empty = Effect::default();
1572 assert!(!serde_json::to_string(&empty)
1573 .unwrap()
1574 .contains("effect_confidence"));
1575 }
1576
1577 struct TrustingWitnessAuthority;
1581 impl WitnessAuthority for TrustingWitnessAuthority {
1582 fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
1583 w.is_signed()
1584 && w.observer != actor
1585 && effect.readback.as_deref() == Some(w.observation.as_str())
1586 }
1587 }
1588
1589 #[test]
1590 fn verify_effect_downgrades_unbacked_verified_claim() {
1591 let mut s = good_stmt();
1593 s.actor = "agent://worker".into();
1594 s.effect = Some(Effect {
1595 output_hash: Some("sha256:out".into()),
1596 effect_confidence: Some(EffectConfidence::Verified),
1597 ..Default::default()
1598 });
1599 let v = verify_effect(&s, &NoWitnessAuthority);
1600 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1601 assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1602 assert!(!v.is_verified());
1603 assert!(
1604 v.notes.iter().any(|n| n.contains("downgraded")),
1605 "{:?}",
1606 v.notes
1607 );
1608 }
1609
1610 #[test]
1613 fn finality_and_confidence_are_independent_axes() {
1614 let mut s = good_stmt();
1618 s.effect = Some(Effect {
1619 output_hash: Some("sha256:out".into()),
1620 effect_confidence: Some(EffectConfidence::Partial),
1621 finality: Some(EffectFinality::Finalized),
1622 ..Default::default()
1623 });
1624 let v = verify_effect(&s, &NoWitnessAuthority);
1625 assert_eq!(v.effective_confidence, EffectConfidence::Partial);
1627 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1629 assert_eq!(v.claimed_finality, Some(EffectFinality::Finalized));
1630 }
1631
1632 #[test]
1633 fn unbacked_finalized_is_downgraded_to_indeterminate() {
1634 let mut s = good_stmt();
1639 s.effect = Some(Effect {
1640 output_hash: Some("sha256:out".into()),
1641 finality: Some(EffectFinality::Finalized),
1642 ..Default::default()
1643 });
1644 let v = verify_effect(&s, &NoWitnessAuthority);
1645 assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1646 assert!(
1647 v.notes.iter().any(|n| n.contains("Finalized")),
1648 "the downgrade must be stated, not silent: {:?}",
1649 v.notes
1650 );
1651 }
1652
1653 #[test]
1654 fn finalized_backed_by_readback_survives() {
1655 let mut s = good_stmt();
1656 s.effect = Some(Effect {
1657 readback: Some("sha256:observed".into()),
1658 finality: Some(EffectFinality::Finalized),
1659 ..Default::default()
1660 });
1661 let v = verify_effect(&s, &NoWitnessAuthority);
1662 assert_eq!(v.effective_finality, Some(EffectFinality::Finalized));
1663 }
1664
1665 #[test]
1666 fn lesser_finality_claims_pass_through_unchanged() {
1667 for stage in [
1670 EffectFinality::NotAttempted,
1671 EffectFinality::Initiated,
1672 EffectFinality::Failed,
1673 EffectFinality::Indeterminate,
1674 ] {
1675 let mut s = good_stmt();
1676 s.effect = Some(Effect {
1677 finality: Some(stage),
1678 ..Default::default()
1679 });
1680 let v = verify_effect(&s, &NoWitnessAuthority);
1681 assert_eq!(v.effective_finality, Some(stage), "{stage:?} was altered");
1682 }
1683 }
1684
1685 #[test]
1686 fn not_attempted_is_the_no_authority_moved_receipt() {
1687 let e = Effect {
1691 input_hash: Some("sha256:req".into()),
1692 finality: Some(EffectFinality::NotAttempted),
1693 ..Default::default()
1694 };
1695 assert!(EffectFinality::NotAttempted.is_resolved());
1696 assert_eq!(
1697 check_resolution(&e, 4_000_000_000),
1698 ResolutionStatus::Resolved
1699 );
1700 }
1701
1702 fn open_effect(resolution: Option<Resolution>) -> Effect {
1705 Effect {
1706 finality: Some(EffectFinality::Initiated),
1707 resolution,
1708 ..Default::default()
1709 }
1710 }
1711
1712 #[test]
1713 fn unresolved_without_a_deadline_reports_indefinite() {
1714 assert_eq!(
1717 check_resolution(&open_effect(None), 1_800_000_000),
1718 ResolutionStatus::Indefinite
1719 );
1720 }
1721
1722 const DEADLINE: &str = "2026-07-20T11:00:00Z";
1726 fn deadline_unix() -> i64 {
1727 parse_rfc3339_to_unix(DEADLINE).expect("fixture deadline parses") as i64
1728 }
1729
1730 #[test]
1731 fn unresolved_past_its_deadline_reports_the_declared_event() {
1732 let e = open_effect(Some(Resolution {
1733 deadline: DEADLINE.into(),
1734 on_deadline: DeadlineEvent::Escalate,
1735 }));
1736 match check_resolution(&e, deadline_unix() + 90) {
1737 ResolutionStatus::Breached {
1738 on_deadline,
1739 seconds_overdue,
1740 } => {
1741 assert_eq!(on_deadline, DeadlineEvent::Escalate);
1742 assert_eq!(seconds_overdue, 90);
1743 }
1744 other => panic!("expected Breached, got {other:?}"),
1745 }
1746 }
1747
1748 #[test]
1749 fn unresolved_inside_its_window_is_pending() {
1750 let e = open_effect(Some(Resolution {
1751 deadline: DEADLINE.into(),
1752 on_deadline: DeadlineEvent::Timeout,
1753 }));
1754 match check_resolution(&e, deadline_unix() - 60) {
1755 ResolutionStatus::Pending { seconds_remaining } => {
1756 assert_eq!(seconds_remaining, 60)
1757 }
1758 other => panic!("expected Pending, got {other:?}"),
1759 }
1760 }
1761
1762 #[test]
1763 fn a_resolved_effect_cannot_breach() {
1764 let e = Effect {
1766 finality: Some(EffectFinality::Finalized),
1767 resolution: Some(Resolution {
1768 deadline: "2026-07-20T11:00:00Z".into(),
1769 on_deadline: DeadlineEvent::Tombstone,
1770 }),
1771 ..Default::default()
1772 };
1773 assert_eq!(
1774 check_resolution(&e, 4_000_000_000),
1775 ResolutionStatus::Resolved
1776 );
1777 }
1778
1779 #[test]
1780 fn unparseable_deadline_fails_toward_unknown() {
1781 let e = open_effect(Some(Resolution {
1784 deadline: "whenever".into(),
1785 on_deadline: DeadlineEvent::Timeout,
1786 }));
1787 assert_eq!(
1788 check_resolution(&e, 1_800_000_000),
1789 ResolutionStatus::BadDeadline
1790 );
1791 }
1792
1793 #[test]
1794 fn missing_finality_is_treated_as_unresolved() {
1795 let e = Effect {
1798 output_hash: Some("sha256:out".into()),
1799 ..Default::default()
1800 };
1801 assert_eq!(
1802 check_resolution(&e, 1_800_000_000),
1803 ResolutionStatus::Indefinite
1804 );
1805 }
1806
1807 #[test]
1808 fn finality_and_resolution_are_omitted_when_absent() {
1809 let json = serde_json::to_string(&Effect {
1811 output_hash: Some("sha256:out".into()),
1812 ..Default::default()
1813 })
1814 .unwrap();
1815 assert!(!json.contains("finality"), "{json}");
1816 assert!(!json.contains("resolution"), "{json}");
1817 }
1818
1819 #[test]
1820 fn verify_effect_honors_verified_backed_by_readback() {
1821 let mut s = good_stmt();
1822 s.effect = Some(Effect {
1823 readback: Some("sha256:observed".into()),
1824 effect_confidence: Some(EffectConfidence::Verified),
1825 ..Default::default()
1826 });
1827 let v = verify_effect(&s, &NoWitnessAuthority);
1828 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1829 assert!(v.is_verified());
1830 }
1831
1832 #[test]
1833 fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1834 let mut s = good_stmt();
1837 s.actor = "agent://worker".into();
1838 s.effect = Some(Effect {
1839 readback: Some("sha256:state".into()),
1840 effect_confidence: Some(EffectConfidence::Verified),
1841 witnesses: vec![Witness {
1842 observer: "agent://auditor".into(),
1843 observation: "sha256:state".into(),
1844 observed_at: Some("2026-07-20T10:00:00Z".into()),
1845 signature: Some("ed25519:sig".into()),
1846 }],
1847 ..Default::default()
1848 });
1849 let v = verify_effect(&s, &TrustingWitnessAuthority);
1850 assert_eq!(v.trusted_witnesses, 1);
1851 assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1852
1853 let mut self_witness = s.clone();
1855 if let Some(e) = self_witness.effect.as_mut() {
1856 e.readback = None; e.witnesses[0].observer = "agent://worker".into();
1858 }
1859 let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1860 assert_eq!(v2.trusted_witnesses, 0);
1861 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1862 assert!(v2
1863 .notes
1864 .iter()
1865 .any(|n| n.contains("not independently trusted")));
1866 }
1867
1868 #[test]
1869 fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1870 for c in [
1873 EffectConfidence::Partial,
1874 EffectConfidence::Ambiguous,
1875 EffectConfidence::Unknown,
1876 EffectConfidence::NotVerified,
1877 ] {
1878 let mut s = good_stmt();
1879 s.effect = Some(Effect {
1880 effect_confidence: Some(c),
1881 ..Default::default()
1882 });
1883 let v = verify_effect(&s, &NoWitnessAuthority);
1884 assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1885 }
1886 }
1887
1888 #[test]
1889 fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1890 let s = good_stmt();
1892 assert!(s.effect.is_none());
1893 let v = verify_effect(&s, &NoWitnessAuthority);
1894 assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1895 assert_eq!(v.claimed_confidence, None);
1896 assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1897
1898 let mut s2 = good_stmt();
1900 s2.effect = Some(Effect {
1901 output_hash: Some("sha256:out".into()),
1902 ..Default::default()
1903 });
1904 let v2 = verify_effect(&s2, &NoWitnessAuthority);
1905 assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1906 assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1907 }
1908
1909 #[test]
1910 fn witness_does_not_inflate_evidence_ceiling() {
1911 let signed_witness = Witness {
1916 observer: "agent://auditor".into(),
1917 observation: "sha256:observed".into(),
1918 observed_at: Some("2026-07-20T10:00:00Z".into()),
1919 signature: Some("ed25519:sig".into()),
1920 };
1921 let e = Effect {
1922 witnesses: vec![signed_witness.clone()],
1923 effect_confidence: Some(EffectConfidence::Verified),
1924 ..Default::default()
1925 };
1926 assert!(!e.has_independent_evidence());
1927 assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1928 assert!(signed_witness.is_signed());
1931 assert_eq!(e.signed_witnesses().count(), 1);
1932
1933 let unsigned = Effect {
1935 witnesses: vec![Witness {
1936 observer: "agent://auditor".into(),
1937 observation: "sha256:observed".into(),
1938 ..Default::default()
1939 }],
1940 ..Default::default()
1941 };
1942 assert_eq!(unsigned.signed_witnesses().count(), 0);
1943 }
1944
1945 #[test]
1946 fn witnesses_serialize_and_omit_when_empty() {
1947 let empty = Effect::default();
1948 assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1949
1950 let e = Effect {
1951 witnesses: vec![Witness {
1952 observer: "key_9f2c".into(),
1953 observation: "sha256:obs".into(),
1954 observed_at: None,
1955 signature: Some("ed25519:sig".into()),
1956 }],
1957 ..Default::default()
1958 };
1959 let j = serde_json::to_string(&e).unwrap();
1960 assert!(j.contains("\"witnesses\":[{"), "{j}");
1961 assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1962 assert!(!j.contains("observed_at"), "{j}");
1964 let back: Effect = serde_json::from_str(&j).unwrap();
1965 assert_eq!(back.witnesses.len(), 1);
1966 assert!(back.witnesses[0].is_signed());
1967 }
1968
1969 #[test]
1970 fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1971 assert!(RuntimeIdentity::default().is_unbound());
1972
1973 let with_model = RuntimeIdentity {
1975 model: Some("claude-opus-4-8".into()),
1976 ..Default::default()
1977 };
1978 assert!(!with_model.is_unbound());
1979
1980 let with_prompt = RuntimeIdentity {
1981 system_prompt_hash: Some("sha256:sys".into()),
1982 ..Default::default()
1983 };
1984 assert!(!with_prompt.is_unbound());
1985 }
1986
1987 #[test]
1988 fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1989 let rt = RuntimeIdentity {
1990 provider: Some("anthropic".into()),
1991 model: Some("claude-opus-4-8".into()),
1992 tool_schema_hash: Some("sha256:tools".into()),
1993 system_prompt_hash: None,
1994 };
1995 let j = serde_json::to_string(&rt).unwrap();
1996 assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1997 assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1998 assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1999 assert!(!j.contains("system_prompt_hash"), "{j}");
2001
2002 let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
2004 assert_eq!(empty, "{}");
2005 let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
2006 assert!(back.is_unbound());
2007 }
2008
2009 #[test]
2010 fn runtime_is_omitted_from_statement_when_absent() {
2011 let s = good_stmt();
2014 assert!(s.runtime.is_none());
2015 let j = serde_json::to_string(&s).unwrap();
2016 assert!(!j.contains("runtime"), "{j}");
2017
2018 let mut with_rt = good_stmt();
2020 with_rt.runtime = Some(RuntimeIdentity {
2021 model: Some("claude-opus-4-8".into()),
2022 ..Default::default()
2023 });
2024 let j2 = serde_json::to_string(&with_rt).unwrap();
2025 assert!(j2.contains("\"runtime\""), "{j2}");
2026 let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
2027 assert_eq!(
2028 back.runtime.unwrap().model.as_deref(),
2029 Some("claude-opus-4-8")
2030 );
2031 }
2032
2033 fn base_mandate() -> Mandate {
2034 Mandate {
2035 grant_id: "grant_9c2f".into(),
2036 grantor: "key_parent".into(),
2037 grantee: Some("key_holder".into()),
2041 issuer_sig: None,
2042 objective_hash: Some("sha256:abc".into()),
2043 scope: vec!["payments.charge".into()],
2044 audience: "acme-payments-api".into(),
2045 parent_request_id: Some("req_7d3e".into()),
2046 delegation_depth: 2,
2047 issued_at: "2026-07-11T19:50:00Z".into(),
2048 expiry: "2026-07-11T20:50:00Z".into(),
2049 max_delegation: 3,
2050 revocation: Revocation {
2051 path: "hub://acme/revocations".into(),
2052 revoked_at: None,
2053 },
2054 chain: Vec::new(),
2055 }
2056 }
2057
2058 fn good_stmt() -> ActionStatementV2 {
2061 let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
2062 s.timestamp = "2026-07-11T19:53:09Z".into();
2063 s.audience = Some("acme-payments-api".into());
2064 s
2065 }
2066
2067 struct StaticRevocation(RevocationStatus);
2068 impl RevocationSource for StaticRevocation {
2069 fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
2070 self.0.clone()
2071 }
2072 }
2073
2074 #[test]
2077 fn scope_exact_and_glob() {
2078 assert!(action_in_scope(
2079 "payments.charge",
2080 &["payments.charge".into()]
2081 ));
2082 assert!(action_in_scope("payments.charge", &["payments.*".into()]));
2083 assert!(action_in_scope("payments", &["payments.*".into()]));
2084 assert!(!action_in_scope(
2085 "payments.refund",
2086 &["payments.charge".into()]
2087 ));
2088 assert!(!action_in_scope("email.send", &["payments.*".into()]));
2089 assert!(!action_in_scope("anything", &["*".into()]));
2091 assert!(action_in_scope("*", &["*".into()]));
2092 }
2093
2094 #[test]
2095 fn empty_scope_authorizes_nothing() {
2096 let mut s = good_stmt();
2097 s.mandate.scope = vec![];
2098 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2099 MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
2100 v => panic!("empty scope must fail, got {v:?}"),
2101 }
2102 }
2103
2104 #[test]
2105 fn action_out_of_scope_fails() {
2106 let mut s = good_stmt();
2107 s.action = "payments.refund".into();
2108 assert!(matches!(
2109 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2110 MandateVerdict::Fail(_)
2111 ));
2112 }
2113
2114 #[test]
2117 fn audience_match_passes_layer() {
2118 let s = good_stmt();
2119 assert_eq!(
2120 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2121 MandateVerdict::Pass
2122 );
2123 }
2124
2125 #[test]
2126 fn audience_mismatch_fails() {
2127 let mut s = good_stmt();
2128 s.audience = Some("evil-api".into());
2129 assert!(matches!(
2130 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2131 MandateVerdict::Fail(_)
2132 ));
2133 }
2134
2135 #[test]
2136 fn missing_action_audience_is_unverified_not_pass() {
2137 let mut s = good_stmt();
2138 s.audience = None;
2139 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2140 MandateVerdict::Unverified(rs) => {
2141 assert!(rs.iter().any(|r| r.contains("recorded no audience")))
2142 }
2143 v => panic!("missing audience must be Unverified, got {v:?}"),
2144 }
2145 }
2146
2147 #[test]
2148 fn empty_mandate_audience_fails() {
2149 let mut s = good_stmt();
2150 s.mandate.audience = "".into();
2151 assert!(matches!(
2152 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2153 MandateVerdict::Fail(_)
2154 ));
2155 }
2156
2157 #[test]
2160 fn signed_before_issued_fails() {
2161 let mut s = good_stmt();
2162 s.timestamp = "2026-07-11T19:49:59Z".into(); assert!(matches!(
2164 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2165 MandateVerdict::Fail(_)
2166 ));
2167 }
2168
2169 #[test]
2170 fn signed_at_expiry_fails() {
2171 let mut s = good_stmt();
2172 s.timestamp = "2026-07-11T20:50:00Z".into(); assert!(matches!(
2174 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2175 MandateVerdict::Fail(_)
2176 ));
2177 }
2178
2179 #[test]
2180 fn signed_within_window_passes() {
2181 let s = good_stmt(); assert_eq!(
2183 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2184 MandateVerdict::Pass
2185 );
2186 }
2187
2188 #[test]
2189 fn malformed_timestamp_fails_closed() {
2190 let mut s = good_stmt();
2191 s.timestamp = "not-a-timestamp".into();
2192 assert!(matches!(
2193 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2194 MandateVerdict::Fail(_)
2195 ));
2196 }
2197
2198 #[test]
2201 fn revoked_after_signing_still_passes() {
2202 let s = good_stmt();
2205 let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
2206 assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
2207 }
2208
2209 #[test]
2210 fn revoked_before_signing_fails() {
2211 let s = good_stmt(); let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
2213 assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
2214 }
2215
2216 #[test]
2217 fn revocation_unknown_is_unverified() {
2218 let s = good_stmt();
2219 match verify_mandate(&s, &NoRevocationSource) {
2220 MandateVerdict::Unverified(rs) => {
2221 assert!(rs
2222 .iter()
2223 .any(|r| r.contains("revocation could not be checked")))
2224 }
2225 v => panic!("no revocation source must be Unverified, got {v:?}"),
2226 }
2227 }
2228
2229 #[test]
2230 fn fail_takes_precedence_over_unverified() {
2231 let mut s = good_stmt();
2234 s.action = "payments.refund".into();
2235 assert!(matches!(
2236 verify_mandate(&s, &NoRevocationSource),
2237 MandateVerdict::Fail(_)
2238 ));
2239 }
2240
2241 #[test]
2242 fn wrong_type_fails() {
2243 let mut s = good_stmt();
2244 s.type_ = "treeship/action/v1".into();
2245 assert!(matches!(
2246 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2247 MandateVerdict::Fail(_)
2248 ));
2249 }
2250
2251 #[test]
2254 fn mandate_is_bound_into_signature() {
2255 let signer = Ed25519Signer::generate("key_test").unwrap();
2256 let pt = payload_type_v2("action");
2257
2258 let a = good_stmt();
2259 let mut b = good_stmt();
2260 b.mandate.scope = vec!["payments.*".into()]; let ra = sign(&pt, &a, &signer).unwrap();
2263 let rb = sign(&pt, &b, &signer).unwrap();
2264 assert_ne!(
2265 ra.artifact_id, rb.artifact_id,
2266 "changing mandate.scope must change the signed artifact id"
2267 );
2268 }
2269
2270 #[test]
2271 fn v2_sign_verify_roundtrip() {
2272 let signer = Ed25519Signer::generate("key_test").unwrap();
2273 let verifier = EnvVerifier::from_signer(&signer);
2274 let pt = payload_type_v2("action");
2275
2276 let mut s = good_stmt();
2277 s.effect = Some(Effect {
2278 output_hash: Some("sha256:out".into()),
2279 readback: Some("sha256:observed".into()),
2280 bytes_moved: Some(1_048_576),
2281 cost: Some(Cost {
2282 unit: "usd_micros".into(),
2283 amount: 4200,
2284 }),
2285 side_effects: vec!["db:users.update".into()],
2286 ..Default::default()
2287 });
2288
2289 let signed = sign(&pt, &s, &signer).unwrap();
2290 verifier.verify(&signed.envelope).unwrap();
2291
2292 let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
2293 assert_eq!(decoded.type_, TYPE_ACTION_V2);
2294 assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
2295 assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
2296 }
2297
2298 #[test]
2299 fn v2_payload_type_differs_from_v1() {
2300 assert_eq!(
2301 payload_type_v2("action"),
2302 "application/vnd.treeship.action.v2+json"
2303 );
2304 assert_ne!(
2305 payload_type_v2("action"),
2306 super::super::payload_type("action")
2307 );
2308 }
2309
2310 #[test]
2313 fn a_bearer_mandate_is_reported_not_passed() {
2314 let mut s = good_stmt();
2318 s.mandate.grantee = None;
2319 match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2320 MandateVerdict::Unverified(r) => assert!(
2321 r.iter().any(|x| x.contains("bearer")),
2322 "the reason must name it: {r:?}"
2323 ),
2324 other => panic!("bearer must not pass silently, got {other:?}"),
2325 }
2326 }
2327
2328 #[test]
2329 fn a_bound_mandate_clears_the_holder_layer() {
2330 let s = good_stmt();
2331 assert_eq!(
2332 verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2333 MandateVerdict::Pass
2334 );
2335 }
2336
2337 #[test]
2338 fn exercisable_by_is_exact_and_bearer_admits_everyone() {
2339 let mut g = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2340 g.grantee = Some("holder-key".into());
2341 assert!(g.binds_holder());
2342 assert!(g.exercisable_by("holder-key"));
2343 assert!(!g.exercisable_by("someone-else"));
2344
2345 g.grantee = None;
2348 assert!(!g.binds_holder());
2349 assert!(g.exercisable_by("anyone-at-all"));
2350 }
2351
2352 #[test]
2353 fn grantee_is_covered_by_the_signed_bytes() {
2354 let mut a = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2357 let mut b = a.clone();
2358 a.grantee = Some("alice".into());
2359 b.grantee = Some("bob".into());
2360 assert_ne!(a.canonical_for_signing(), b.canonical_for_signing());
2361 assert_ne!(a.derive_grant_id(), b.derive_grant_id());
2362 }
2363
2364 fn grant(
2367 id: &str,
2368 grantor: &str,
2369 scope: &[&str],
2370 depth: u32,
2371 expiry: &str,
2372 max_deleg: u32,
2373 ) -> Grant {
2374 Grant {
2375 grant_id: id.into(),
2376 grantor: grantor.into(),
2377 grantee: None,
2378 issuer_sig: None,
2379 scope: scope.iter().map(|s| (*s).into()).collect(),
2380 audience: "acme-payments-api".into(),
2381 parent_request_id: None,
2382 parent_grant_id: None,
2383 delegation_depth: depth,
2384 issued_at: "2026-07-11T19:00:00Z".into(),
2385 expiry: expiry.into(),
2386 max_delegation: max_deleg,
2387 objective_hash: None,
2388 }
2389 }
2390
2391 #[test]
2392 fn grant_sign_verify_roundtrip_and_tamper() {
2393 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2394 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2395 let mut g = grant(
2396 "grant_root",
2397 &grantor,
2398 &["payments.*"],
2399 0,
2400 "2026-07-11T21:00:00Z",
2401 3,
2402 );
2403 g.grant_id = g.derive_grant_id();
2406
2407 let sig = g.sign_canonical(&signer).unwrap();
2408 assert!(g.verify_canonical(&sig));
2409
2410 g.scope.push("email.*".into());
2412 assert!(!g.verify_canonical(&sig));
2413 }
2414
2415 #[test]
2416 fn grant_verify_rejects_wrong_key() {
2417 let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2418 let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
2419 let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2420 let g = grant(
2421 "grant_root",
2422 &grantor,
2423 &["payments.*"],
2424 0,
2425 "2026-07-11T21:00:00Z",
2426 3,
2427 );
2428 let sig = g.sign_canonical(&attacker).unwrap();
2429 assert!(!g.verify_canonical(&sig));
2430 }
2431
2432 #[test]
2433 fn valid_attenuating_chain_ok() {
2434 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2435 let child = grant(
2436 "g1",
2437 "k",
2438 &["payments.charge"],
2439 1,
2440 "2026-07-11T20:30:00Z",
2441 3,
2442 );
2443 assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
2444 }
2445
2446 #[test]
2447 fn scope_widening_rejected() {
2448 let root = grant(
2449 "g0",
2450 "k",
2451 &["payments.charge"],
2452 0,
2453 "2026-07-11T21:00:00Z",
2454 3,
2455 );
2456 let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
2457 assert_eq!(
2458 verify_grant_chain(&[root, child]),
2459 Err(GrantChainError::ScopeWidened { parent: 0 })
2460 );
2461 }
2462
2463 #[test]
2464 fn expiry_widening_rejected() {
2465 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2466 let child = grant(
2467 "g1",
2468 "k",
2469 &["payments.charge"],
2470 1,
2471 "2026-07-11T22:00:00Z",
2472 3,
2473 );
2474 assert_eq!(
2475 verify_grant_chain(&[root, child]),
2476 Err(GrantChainError::ExpiryWidened { parent: 0 })
2477 );
2478 }
2479
2480 #[test]
2481 fn depth_not_incremented_rejected() {
2482 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2483 let child = grant(
2484 "g1",
2485 "k",
2486 &["payments.charge"],
2487 2,
2488 "2026-07-11T21:00:00Z",
2489 3,
2490 );
2491 assert_eq!(
2492 verify_grant_chain(&[root, child]),
2493 Err(GrantChainError::DepthNotIncremented { parent: 0 })
2494 );
2495 }
2496
2497 #[test]
2498 fn depth_exceeds_max_rejected() {
2499 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
2500 let child = grant(
2501 "g1",
2502 "k",
2503 &["payments.charge"],
2504 1,
2505 "2026-07-11T21:00:00Z",
2506 0,
2507 );
2508 assert_eq!(
2509 verify_grant_chain(&[root, child]),
2510 Err(GrantChainError::DepthExceedsMax { parent: 0 })
2511 );
2512 }
2513
2514 #[test]
2515 fn audience_change_rejected() {
2516 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2517 let mut child = grant(
2518 "g1",
2519 "k",
2520 &["payments.charge"],
2521 1,
2522 "2026-07-11T21:00:00Z",
2523 3,
2524 );
2525 child.audience = "other-api".into();
2526 assert_eq!(
2527 verify_grant_chain(&[root, child]),
2528 Err(GrantChainError::AudienceChanged { parent: 0 })
2529 );
2530 }
2531
2532 #[test]
2533 fn empty_chain_rejected() {
2534 assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
2535 }
2536
2537 #[test]
2538 fn bad_timestamp_in_chain_rejected() {
2539 let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2540 root.expiry = "nope".into();
2541 assert_eq!(
2542 verify_grant_chain(&[root]),
2543 Err(GrantChainError::BadTimestamp { index: 0 })
2544 );
2545 }
2546
2547 #[test]
2548 fn single_grant_chain_ok() {
2549 let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2550 assert_eq!(verify_grant_chain(&[root]), Ok(()));
2551 }
2552
2553 fn mk_grant(
2557 signer: &Ed25519Signer,
2558 grantor_pk: &str,
2559 scope: Vec<&str>,
2560 depth: u32,
2561 parent: Option<&str>,
2562 ) -> Grant {
2563 let mut g = Grant {
2564 grant_id: String::new(),
2565 grantor: grantor_pk.to_string(),
2566 grantee: None,
2567 issuer_sig: None,
2568 scope: scope.into_iter().map(String::from).collect(),
2569 audience: "acme".into(),
2570 parent_request_id: None,
2571 parent_grant_id: parent.map(String::from),
2572 delegation_depth: depth,
2573 issued_at: "2026-07-20T10:00:00Z".into(),
2574 expiry: "2026-07-20T11:00:00Z".into(),
2575 max_delegation: 3,
2576 objective_hash: None,
2577 };
2578 g.grant_id = g.derive_grant_id();
2579 g.issuer_sig = Some(g.sign_canonical(signer).unwrap());
2580 g
2581 }
2582
2583 fn chain_fixture() -> (Grant, Grant, Ed25519Signer) {
2584 let signer = Ed25519Signer::generate("issuer").unwrap();
2585 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2586 let root = mk_grant(&signer, &pk, vec!["payments.*"], 0, None);
2587 let leaf = mk_grant(
2588 &signer,
2589 &pk,
2590 vec!["payments.charge"],
2591 1,
2592 Some(&root.grant_id),
2593 );
2594 (root, leaf, signer)
2595 }
2596
2597 fn mandate_with(leaf: &Grant, chain: Vec<Grant>) -> Mandate {
2598 let mut m = base_mandate();
2599 m.grant_id = leaf.grant_id.clone();
2600 m.chain = chain;
2601 m
2602 }
2603
2604 #[test]
2605 fn grant_id_is_content_derived_and_stable() {
2606 let (root, _, _) = chain_fixture();
2607 assert!(root.grant_id.starts_with("grn_"));
2608 assert_eq!(root.grant_id, root.derive_grant_id());
2609 let mut altered = root.clone();
2611 altered.scope = vec!["payments.refund".into()];
2612 assert_ne!(altered.derive_grant_id(), root.grant_id);
2613 }
2614
2615 #[test]
2616 fn hand_chosen_id_fails_verification() {
2617 let (mut root, _, _) = chain_fixture();
2618 let sig = root.issuer_sig.clone().unwrap();
2619 root.grant_id = "grn_deadbeefdeadbeef".into();
2620 assert!(
2621 !root.verify_canonical(&sig),
2622 "an id that was chosen rather than computed must not verify"
2623 );
2624 }
2625
2626 #[test]
2627 fn resolves_root_first_regardless_of_carrier_order() {
2628 let (root, leaf, _) = chain_fixture();
2629 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2631 let resolved = resolve_grant_chain(&m).expect("resolves");
2632 assert_eq!(resolved.len(), 2);
2633 assert_eq!(resolved[0].grant_id, root.grant_id, "root must come first");
2634 assert_eq!(resolved[1].grant_id, leaf.grant_id);
2635 }
2636
2637 #[test]
2638 fn truncated_chain_is_rejected() {
2639 let (_, leaf, _) = chain_fixture();
2640 let m = mandate_with(&leaf, vec![leaf.clone()]);
2643 match resolve_grant_chain(&m) {
2644 Err(ChainResolveError::AncestorMissing { .. }) => {}
2645 other => panic!("truncation must be caught, got {other:?}"),
2646 }
2647 }
2648
2649 #[test]
2650 fn spliced_decoy_grant_is_rejected() {
2651 let (root, leaf, signer) = chain_fixture();
2652 let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2653 let decoy = mk_grant(&signer, &pk, vec!["email.send"], 0, None);
2657 assert_ne!(decoy.grant_id, root.grant_id);
2658 let m = mandate_with(&leaf, vec![root.clone(), leaf.clone(), decoy]);
2659 match resolve_grant_chain(&m) {
2660 Err(ChainResolveError::UnreachableExtras { count }) => assert_eq!(count, 1),
2661 other => panic!("unreachable extras must be refused, got {other:?}"),
2662 }
2663 }
2664
2665 #[test]
2666 fn unsigned_ancestor_is_rejected() {
2667 let (mut root, leaf, _) = chain_fixture();
2668 root.issuer_sig = None;
2669 let m = mandate_with(&leaf, vec![root, leaf.clone()]);
2670 assert!(matches!(
2671 resolve_grant_chain(&m),
2672 Err(ChainResolveError::Unsigned { .. })
2673 ));
2674 }
2675
2676 #[test]
2677 fn resolved_chain_feeds_attenuation_check() {
2678 let (root, leaf, _) = chain_fixture();
2681 let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2682 let resolved = resolve_grant_chain(&m).expect("resolves");
2683 assert!(
2684 verify_grant_chain(&resolved).is_ok(),
2685 "narrowing scope at depth+1 must satisfy attenuation"
2686 );
2687 }
2688
2689 #[test]
2690 fn leaf_not_in_chain_is_rejected() {
2691 let (root, leaf, _) = chain_fixture();
2695 let mut m = mandate_with(&leaf, vec![root.clone()]);
2696 m.grant_id = leaf.grant_id.clone();
2697 match resolve_grant_chain(&m) {
2698 Err(ChainResolveError::LeafMissing { grant_id }) => {
2699 assert_eq!(grant_id, leaf.grant_id);
2700 }
2701 other => panic!("a mandate naming an absent leaf must fail, got {other:?}"),
2702 }
2703 }
2704
2705 #[test]
2706 fn ancestor_signed_by_a_stranger_is_rejected() {
2707 let (root, leaf, _) = chain_fixture();
2711 let stranger = Ed25519Signer::generate("stranger").unwrap();
2712 let mut forged = root.clone();
2713 forged.issuer_sig = Some(forged.sign_canonical(&stranger).unwrap());
2714 assert_eq!(
2715 forged.grant_id, root.grant_id,
2716 "signing with another key must not change the content id"
2717 );
2718
2719 let m = mandate_with(&leaf, vec![forged, leaf.clone()]);
2720 match resolve_grant_chain(&m) {
2721 Err(ChainResolveError::BadSignature { grant_id }) => {
2722 assert_eq!(grant_id, root.grant_id);
2723 }
2724 other => panic!("a grant signed by a non-grantor must fail, got {other:?}"),
2725 }
2726 }
2727
2728 #[test]
2729 fn inconsistent_id_is_caught_before_signature_check() {
2730 let (root, leaf, _) = chain_fixture();
2734 let mut tampered = root.clone();
2735 tampered.grant_id = "grn_0000000000000000".into();
2736 let m = mandate_with(&leaf, vec![tampered, leaf.clone()]);
2737 assert!(matches!(
2738 resolve_grant_chain(&m),
2739 Err(ChainResolveError::InconsistentId { .. })
2740 ));
2741 }
2742 #[test]
2754 fn mandate_cannot_claim_more_than_the_grant_it_names() {
2755 let (root, leaf, _) = chain_fixture();
2756 assert_eq!(leaf.scope, vec!["payments.charge".to_string()]);
2757
2758 let mut m = mandate_with(&leaf, vec![root.clone(), leaf.clone()]);
2759 m.scope = vec!["payments.*".into()];
2760 m.audience = leaf.audience.clone();
2761
2762 let chain = resolve_grant_chain(&m).expect("chain resolves");
2764 assert_eq!(verify_grant_chain(&chain), Ok(()));
2765
2766 let mut stmt = good_stmt();
2767 stmt.action = "payments.refund".into();
2768 stmt.audience = Some(leaf.audience.clone());
2769 stmt.mandate = m;
2770
2771 match verify_mandate(&stmt, &StaticRevocation(RevocationStatus::NotRevoked)) {
2772 MandateVerdict::Fail(reasons) => assert!(
2773 reasons.iter().any(|r| r.contains("exceeds the leaf grant")),
2774 "failed for the wrong reason: {reasons:?}"
2775 ),
2776 other => panic!("an action outside the leaf grant must FAIL, got {other:?}"),
2777 }
2778 }
2779
2780 #[test]
2783 fn a_mandate_within_its_grant_still_passes() {
2784 let (root, leaf, _) = chain_fixture();
2785 let mut m = mandate_with(&leaf, vec![root.clone(), leaf.clone()]);
2786 m.scope = leaf.scope.clone();
2787 m.audience = leaf.audience.clone();
2788
2789 let mut stmt = good_stmt();
2790 stmt.action = "payments.charge".into();
2791 stmt.audience = Some(leaf.audience.clone());
2792 stmt.mandate = m;
2793
2794 assert_eq!(
2795 verify_mandate(&stmt, &StaticRevocation(RevocationStatus::NotRevoked)),
2796 MandateVerdict::Pass
2797 );
2798 }
2799
2800 #[test]
2804 fn objective_cannot_change_or_be_dropped_across_a_delegation() {
2805 let (root, leaf, _) = chain_fixture();
2806
2807 let mut p = root.clone();
2808 p.objective_hash = Some("sha256:task-a".into());
2809
2810 let mut swapped = leaf.clone();
2811 swapped.objective_hash = Some("sha256:task-b".into());
2812 assert_eq!(
2813 verify_grant_chain(&[p.clone(), swapped]),
2814 Err(GrantChainError::ObjectiveChanged { parent: 0 })
2815 );
2816
2817 let mut dropped = leaf.clone();
2818 dropped.objective_hash = None;
2819 assert_eq!(
2820 verify_grant_chain(&[p.clone(), dropped]),
2821 Err(GrantChainError::ObjectiveChanged { parent: 0 })
2822 );
2823
2824 let mut added = leaf.clone();
2826 added.objective_hash = Some("sha256:task-a".into());
2827 assert_eq!(verify_grant_chain(&[root.clone(), added]), Ok(()));
2828
2829 let mut same = leaf.clone();
2831 same.objective_hash = Some("sha256:task-a".into());
2832 assert_eq!(verify_grant_chain(&[p, same]), Ok(()));
2833 }
2834}