1use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41
42pub const POLICY_SCHEMA: &str = "vela.registry_governance_policy.v0.1";
44
45pub const OWNER_ROTATE_PROPOSAL_SCHEMA: &str = "vela.owner_rotate_proposal.v0.1";
47
48pub const OWNER_ROTATE_BUNDLE_SCHEMA: &str = "vela.owner_rotate_attestation_bundle.v0.1";
50
51pub const OWNER_EPOCH_CHAIN_SCHEMA: &str = "vela.owner_epoch_chain.v0.1";
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct GovernancePolicy {
58 pub schema: String,
59 pub policy_id: String,
62 pub frontier_id: String,
64 pub owner_epoch: u64,
66 pub bootstrap_epoch: u64,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub valid_from_entry_hash: Option<String>,
78 pub rotate_quorum: Quorum,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub emergency_quorum: Option<Quorum>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub policy_update_quorum: Option<Quorum>,
83 #[serde(default = "default_attestation_ttl_hours")]
86 pub attestation_ttl_hours: u32,
87 pub created_at: String,
88}
89
90fn default_attestation_ttl_hours() -> u32 {
91 168
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct Quorum {
98 pub threshold: u32,
99 pub eligible_actors: Vec<String>,
100 pub current_owner_counts: bool,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub role_constraints: Option<RoleConstraints>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub timelock_hours: Option<u32>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
116pub struct RoleConstraints {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub min_domain_maintainers: Option<u32>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub min_registry_stewards: Option<u32>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub min_independent_stewards: Option<u32>,
123}
124
125#[derive(Debug, Clone)]
128pub struct PolicyDraft {
129 pub frontier_id: String,
130 pub owner_epoch: u64,
131 pub bootstrap_epoch: u64,
132 pub rotate_quorum: Quorum,
133 pub emergency_quorum: Option<Quorum>,
134 pub policy_update_quorum: Option<Quorum>,
135 pub attestation_ttl_hours: u32,
136 pub created_at: String,
137}
138
139impl GovernancePolicy {
140 pub fn from_draft(draft: PolicyDraft) -> Result<Self, String> {
144 let mut policy = GovernancePolicy {
145 schema: POLICY_SCHEMA.to_string(),
146 policy_id: String::new(),
147 frontier_id: draft.frontier_id,
148 owner_epoch: draft.owner_epoch,
149 bootstrap_epoch: draft.bootstrap_epoch,
150 valid_from_entry_hash: None,
151 rotate_quorum: draft.rotate_quorum,
152 emergency_quorum: draft.emergency_quorum,
153 policy_update_quorum: draft.policy_update_quorum,
154 attestation_ttl_hours: draft.attestation_ttl_hours,
155 created_at: draft.created_at,
156 };
157 policy.policy_id = policy.derive_id()?;
158 policy.validate()?;
159 Ok(policy)
160 }
161
162 pub fn derive_id(&self) -> Result<String, String> {
166 let mut preimage = self.clone();
167 preimage.policy_id = String::new();
168 preimage.valid_from_entry_hash = None;
169 let bytes = crate::canonical::to_canonical_bytes(&preimage)
170 .map_err(|e| format!("canonicalize policy: {e}"))?;
171 let digest = Sha256::digest(&bytes);
172 Ok(format!("vgp_{}", &hex::encode(digest)[..16]))
173 }
174
175 pub fn validate(&self) -> Result<(), String> {
178 if self.schema != POLICY_SCHEMA {
179 return Err(format!(
180 "policy.schema must be `{POLICY_SCHEMA}`, got `{}`",
181 self.schema
182 ));
183 }
184 if !self.policy_id.starts_with("vgp_") {
185 return Err(format!(
186 "policy.policy_id must start with `vgp_`, got `{}`",
187 self.policy_id
188 ));
189 }
190 if !self.frontier_id.starts_with("vfr_") {
191 return Err(format!(
192 "policy.frontier_id must start with `vfr_`, got `{}`",
193 self.frontier_id
194 ));
195 }
196 if self.attestation_ttl_hours == 0 {
197 return Err("policy.attestation_ttl_hours must be >= 1".to_string());
198 }
199 if self.bootstrap_epoch > self.owner_epoch {
200 return Err(format!(
201 "policy.bootstrap_epoch ({}) must be <= owner_epoch ({})",
202 self.bootstrap_epoch, self.owner_epoch
203 ));
204 }
205 validate_quorum(&self.rotate_quorum, "rotate_quorum")?;
206 if let Some(q) = &self.emergency_quorum {
207 validate_quorum(q, "emergency_quorum")?;
208 }
209 if let Some(q) = &self.policy_update_quorum {
210 validate_quorum(q, "policy_update_quorum")?;
211 if q.threshold < self.rotate_quorum.threshold {
212 return Err(format!(
213 "policy_update_quorum.threshold ({}) must be >= rotate_quorum.threshold ({}); \
214 a lower threshold lets a compromised quorum weaken governance",
215 q.threshold, self.rotate_quorum.threshold
216 ));
217 }
218 }
219
220 let is_bootstrap = self.bootstrap_epoch == 0 && self.owner_epoch == 0;
227 if self.rotate_quorum.current_owner_counts && !is_bootstrap {
228 return Err(format!(
229 "rotate_quorum.current_owner_counts = true is only permitted for bootstrap \
230 policies (bootstrap_epoch == 0 AND owner_epoch == 0); got bootstrap_epoch={}, \
231 owner_epoch={}",
232 self.bootstrap_epoch, self.owner_epoch
233 ));
234 }
235
236 Ok(())
237 }
238
239 pub fn verify_content_address(&self) -> Result<(), String> {
242 let derived = self.derive_id()?;
243 if derived != self.policy_id {
244 return Err(format!(
245 "policy_id mismatch: stored `{}`, derived `{}`",
246 self.policy_id, derived
247 ));
248 }
249 Ok(())
250 }
251}
252
253fn validate_quorum(q: &Quorum, label: &str) -> Result<(), String> {
254 if q.threshold == 0 {
255 return Err(format!("{label}.threshold must be >= 1"));
256 }
257 if q.eligible_actors.is_empty() {
258 return Err(format!("{label}.eligible_actors must be non-empty"));
259 }
260 let count = q.eligible_actors.len() as u32;
261 if q.threshold > count {
262 return Err(format!(
263 "{label}.threshold ({}) cannot exceed eligible_actors count ({})",
264 q.threshold, count
265 ));
266 }
267 let mut seen = std::collections::BTreeSet::new();
269 for actor in &q.eligible_actors {
270 if !seen.insert(actor) {
271 return Err(format!(
272 "{label}.eligible_actors contains duplicate id `{actor}`; each actor counts once \
273 toward quorum"
274 ));
275 }
276 }
277 if let Some(rc) = &q.role_constraints {
278 let total_min: u32 = rc.min_domain_maintainers.unwrap_or(0)
279 + rc.min_registry_stewards.unwrap_or(0)
280 + rc.min_independent_stewards.unwrap_or(0);
281 if total_min > q.threshold {
282 return Err(format!(
283 "{label}.role_constraints sum ({total_min}) exceeds threshold ({}); the \
284 constraints cannot be satisfied within a quorum of that size",
285 q.threshold
286 ));
287 }
288 }
289 Ok(())
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
299pub struct OwnerRotateProposal {
300 pub schema: String,
301 pub proposal_id: String,
302 pub frontier_id: String,
303 pub old_owner_actor_id: String,
304 pub old_owner_pubkey: String,
305 pub new_owner_actor_id: String,
306 pub new_owner_pubkey: String,
307 pub owner_epoch: u64,
308 pub previous_registry_entry_hash: String,
309 pub governance_policy_id: String,
310 pub reason: String,
311 pub created_at: String,
312 pub expires_at: String,
313 pub nonce: String,
314}
315
316#[derive(Debug, Clone)]
318pub struct ProposalDraft {
319 pub frontier_id: String,
320 pub old_owner_actor_id: String,
321 pub old_owner_pubkey: String,
322 pub new_owner_actor_id: String,
323 pub new_owner_pubkey: String,
324 pub owner_epoch: u64,
325 pub previous_registry_entry_hash: String,
326 pub governance_policy_id: String,
327 pub reason: String,
328 pub created_at: String,
329 pub expires_at: String,
330 pub nonce: String,
331}
332
333impl OwnerRotateProposal {
334 pub fn from_draft(draft: ProposalDraft) -> Result<Self, String> {
337 if draft.owner_epoch == 0 {
338 return Err(
339 "owner_epoch must be >= 1; the first governed rotation produces owner_epoch=1"
340 .to_string(),
341 );
342 }
343 if draft.reason.trim().is_empty() {
344 return Err("reason must be non-empty".to_string());
345 }
346 let mut proposal = OwnerRotateProposal {
347 schema: OWNER_ROTATE_PROPOSAL_SCHEMA.to_string(),
348 proposal_id: String::new(),
349 frontier_id: draft.frontier_id,
350 old_owner_actor_id: draft.old_owner_actor_id,
351 old_owner_pubkey: draft.old_owner_pubkey,
352 new_owner_actor_id: draft.new_owner_actor_id,
353 new_owner_pubkey: draft.new_owner_pubkey,
354 owner_epoch: draft.owner_epoch,
355 previous_registry_entry_hash: draft.previous_registry_entry_hash,
356 governance_policy_id: draft.governance_policy_id,
357 reason: draft.reason,
358 created_at: draft.created_at,
359 expires_at: draft.expires_at,
360 nonce: draft.nonce,
361 };
362 proposal.proposal_id = proposal.derive_id()?;
363 Ok(proposal)
364 }
365
366 pub fn derive_id(&self) -> Result<String, String> {
369 let mut preimage = self.clone();
370 preimage.proposal_id = String::new();
371 let bytes = crate::canonical::to_canonical_bytes(&preimage)
372 .map_err(|e| format!("canonicalize proposal: {e}"))?;
373 let digest = Sha256::digest(&bytes);
374 Ok(format!("vop_{}", &hex::encode(digest)[..16]))
375 }
376
377 pub fn preimage_bytes(&self) -> Result<Vec<u8>, String> {
381 let mut preimage = self.clone();
382 preimage.proposal_id = String::new();
383 crate::canonical::to_canonical_bytes(&preimage)
384 .map_err(|e| format!("canonicalize proposal preimage: {e}"))
385 }
386
387 pub fn preimage_hash(&self) -> Result<String, String> {
390 let bytes = self.preimage_bytes()?;
391 let digest = Sha256::digest(&bytes);
392 Ok(format!("sha256:{}", hex::encode(digest)))
393 }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398pub struct OwnerRotateAttestationBundle {
399 pub schema: String,
400 pub bundle_id: String,
401 pub proposal_id: String,
402 pub proposal_preimage_hash: String,
403 pub attestations: Vec<AttestationEntry>,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
407pub struct AttestationEntry {
408 pub attester_id: String,
409 pub attester_pubkey: String,
410 pub judgment: String,
411 pub signature: String,
412 pub signed_at: String,
413}
414
415impl OwnerRotateAttestationBundle {
416 pub fn new(
420 proposal: &OwnerRotateProposal,
421 attestations: Vec<AttestationEntry>,
422 ) -> Result<Self, String> {
423 let preimage_hash = proposal.preimage_hash()?;
424 let mut bundle = OwnerRotateAttestationBundle {
425 schema: OWNER_ROTATE_BUNDLE_SCHEMA.to_string(),
426 bundle_id: String::new(),
427 proposal_id: proposal.proposal_id.clone(),
428 proposal_preimage_hash: preimage_hash,
429 attestations,
430 };
431 bundle.bundle_id = bundle.derive_id()?;
432 Ok(bundle)
433 }
434
435 pub fn derive_id(&self) -> Result<String, String> {
436 let mut preimage = self.clone();
437 preimage.bundle_id = String::new();
438 let bytes = crate::canonical::to_canonical_bytes(&preimage)
439 .map_err(|e| format!("canonicalize bundle: {e}"))?;
440 let digest = Sha256::digest(&bytes);
441 Ok(format!("vab_{}", &hex::encode(digest)[..16]))
442 }
443}
444
445pub trait ActorRevocationLookup {
450 fn revoked_at(&self, actor_id: &str) -> Option<&str>;
451}
452
453#[derive(Debug, Clone, Serialize)]
455pub struct QuorumReport {
456 pub proposal_id: String,
457 pub bundle_id: String,
458 pub policy_id: String,
459 pub threshold: u32,
460 pub approving_signers: Vec<String>,
461 pub current_owner_counted: bool,
462}
463
464pub fn verify_quorum(
483 proposal: &OwnerRotateProposal,
484 bundle: &OwnerRotateAttestationBundle,
485 policy: &GovernancePolicy,
486 revocation: &(impl ActorRevocationLookup + ?Sized),
487 now: &str,
488) -> Result<QuorumReport, String> {
489 if proposal.schema != OWNER_ROTATE_PROPOSAL_SCHEMA {
490 return Err(format!(
491 "proposal.schema must be `{OWNER_ROTATE_PROPOSAL_SCHEMA}`, got `{}`",
492 proposal.schema
493 ));
494 }
495 if bundle.schema != OWNER_ROTATE_BUNDLE_SCHEMA {
496 return Err(format!(
497 "bundle.schema must be `{OWNER_ROTATE_BUNDLE_SCHEMA}`, got `{}`",
498 bundle.schema
499 ));
500 }
501 if bundle.proposal_id != proposal.proposal_id {
502 return Err(format!(
503 "bundle.proposal_id `{}` does not match proposal.proposal_id `{}`",
504 bundle.proposal_id, proposal.proposal_id
505 ));
506 }
507 let expected_hash = proposal.preimage_hash()?;
508 if bundle.proposal_preimage_hash != expected_hash {
509 return Err(format!(
510 "bundle.proposal_preimage_hash mismatch: stored `{}`, derived `{}`",
511 bundle.proposal_preimage_hash, expected_hash
512 ));
513 }
514 if proposal.governance_policy_id != policy.policy_id {
515 return Err(format!(
516 "proposal.governance_policy_id `{}` does not match policy.policy_id `{}`",
517 proposal.governance_policy_id, policy.policy_id
518 ));
519 }
520 if policy.frontier_id != proposal.frontier_id {
521 return Err(format!(
522 "policy.frontier_id `{}` does not match proposal.frontier_id `{}`",
523 policy.frontier_id, proposal.frontier_id
524 ));
525 }
526 if policy.owner_epoch + 1 != proposal.owner_epoch {
530 return Err(format!(
531 "proposal.owner_epoch ({}) must equal policy.owner_epoch ({}) + 1",
532 proposal.owner_epoch, policy.owner_epoch
533 ));
534 }
535 if now > proposal.expires_at.as_str() {
537 return Err(format!(
538 "proposal expired at {} (now: {})",
539 proposal.expires_at, now
540 ));
541 }
542
543 let preimage_bytes = proposal.preimage_bytes()?;
544
545 let eligible: std::collections::BTreeSet<&str> = policy
551 .rotate_quorum
552 .eligible_actors
553 .iter()
554 .map(String::as_str)
555 .collect();
556
557 let mut approving_signers: std::collections::BTreeSet<String> =
558 std::collections::BTreeSet::new();
559 let mut current_owner_counted = false;
560
561 for att in &bundle.attestations {
562 if att.judgment != "approve_owner_rotate" {
563 continue;
564 }
565 let is_eligible = eligible.contains(att.attester_id.as_str());
567 let is_current_owner = att.attester_id == proposal.old_owner_actor_id;
568 if !is_eligible && !(is_current_owner && policy.rotate_quorum.current_owner_counts) {
569 return Err(format!(
570 "attester `{}` is not in rotate_quorum.eligible_actors and the policy does not \
571 admit the current owner (current_owner_counts=false)",
572 att.attester_id
573 ));
574 }
575 if let Some(revoked_at) = revocation.revoked_at(&att.attester_id)
577 && revoked_at.as_bytes() <= att.signed_at.as_bytes()
578 {
579 return Err(format!(
580 "attester `{}` was revoked at {} (>= signed_at {})",
581 att.attester_id, revoked_at, att.signed_at
582 ));
583 }
584 let pk_bytes = hex::decode(&att.attester_pubkey)
586 .map_err(|e| format!("attester `{}` pubkey not hex: {e}", att.attester_id))?;
587 if pk_bytes.len() != 32 {
588 return Err(format!(
589 "attester `{}` pubkey must be 32 bytes (got {})",
590 att.attester_id,
591 pk_bytes.len()
592 ));
593 }
594 let pk = ed25519_dalek::VerifyingKey::from_bytes(
595 pk_bytes
596 .as_slice()
597 .try_into()
598 .map_err(|e| format!("attester `{}` pubkey: {e}", att.attester_id))?,
599 )
600 .map_err(|e| format!("attester `{}` pubkey malformed: {e}", att.attester_id))?;
601 let sig_bytes = hex::decode(&att.signature)
602 .map_err(|e| format!("attester `{}` signature not hex: {e}", att.attester_id))?;
603 if sig_bytes.len() != 64 {
604 return Err(format!(
605 "attester `{}` signature must be 64 bytes (got {})",
606 att.attester_id,
607 sig_bytes.len()
608 ));
609 }
610 let sig = ed25519_dalek::Signature::from_bytes(
611 sig_bytes
612 .as_slice()
613 .try_into()
614 .map_err(|e| format!("attester `{}` signature: {e}", att.attester_id))?,
615 );
616 use ed25519_dalek::Verifier;
617 pk.verify(&preimage_bytes, &sig).map_err(|e| {
618 format!(
619 "attester `{}` signature does not verify against proposal preimage: {e}",
620 att.attester_id
621 )
622 })?;
623
624 if approving_signers.insert(att.attester_id.clone()) && is_current_owner {
627 current_owner_counted = true;
628 }
629 }
630
631 let count = approving_signers.len() as u32;
632 if count < policy.rotate_quorum.threshold {
633 return Err(format!(
634 "quorum not met: {} distinct approving signer(s); threshold is {}",
635 count, policy.rotate_quorum.threshold
636 ));
637 }
638
639 Ok(QuorumReport {
640 proposal_id: proposal.proposal_id.clone(),
641 bundle_id: bundle.bundle_id.clone(),
642 policy_id: policy.policy_id.clone(),
643 threshold: policy.rotate_quorum.threshold,
644 approving_signers: approving_signers.into_iter().collect(),
645 current_owner_counted,
646 })
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
652pub struct OwnerEpochChain {
653 pub schema: String,
654 pub frontier_id: String,
655 pub transitions: Vec<ChainTransition>,
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
659pub struct ChainTransition {
660 pub owner_epoch: u64,
661 pub policy_id: String,
662 pub proposal_id: String,
663 pub bundle_id: String,
664 pub previous_entry_hash: String,
665 pub new_owner_actor_id: String,
666 pub new_owner_pubkey: String,
667 pub signed_at: String,
668}
669
670impl OwnerEpochChain {
671 pub fn new(frontier_id: String) -> Self {
672 OwnerEpochChain {
673 schema: OWNER_EPOCH_CHAIN_SCHEMA.to_string(),
674 frontier_id,
675 transitions: Vec::new(),
676 }
677 }
678
679 pub fn append(&mut self, t: ChainTransition) -> Result<(), String> {
684 let expected_epoch = self
685 .transitions
686 .last()
687 .map_or(1, |last| last.owner_epoch + 1);
688 if t.owner_epoch != expected_epoch {
689 return Err(format!(
690 "chain transition owner_epoch {} does not match expected {}",
691 t.owner_epoch, expected_epoch
692 ));
693 }
694 self.transitions.push(t);
695 Ok(())
696 }
697}
698
699#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
701#[serde(rename_all = "snake_case")]
702pub enum ChainStatus {
703 Bootstrap,
707 Verified,
710 Legacy,
714 Broken,
717}
718
719pub fn verify_chain(
735 chain: &OwnerEpochChain,
736 policies: &std::collections::HashMap<String, GovernancePolicy>,
737 proposals: &std::collections::HashMap<String, OwnerRotateProposal>,
738 bundles: &std::collections::HashMap<String, OwnerRotateAttestationBundle>,
739 revocation: &dyn ActorRevocationLookup,
740 now: &str,
741) -> ChainStatus {
742 if chain.schema != OWNER_EPOCH_CHAIN_SCHEMA {
743 return ChainStatus::Broken;
744 }
745 if chain.transitions.is_empty() {
746 return ChainStatus::Bootstrap;
747 }
748 let mut expected_epoch = 1u64;
749 for transition in &chain.transitions {
750 if transition.owner_epoch != expected_epoch {
751 return ChainStatus::Broken;
752 }
753 let policy = match policies.get(&transition.policy_id) {
754 Some(p) => p,
755 None => return ChainStatus::Broken,
756 };
757 let proposal = match proposals.get(&transition.proposal_id) {
758 Some(p) => p,
759 None => return ChainStatus::Broken,
760 };
761 let bundle = match bundles.get(&transition.bundle_id) {
762 Some(b) => b,
763 None => return ChainStatus::Broken,
764 };
765 if proposal.previous_registry_entry_hash != transition.previous_entry_hash {
766 return ChainStatus::Broken;
767 }
768 if proposal.new_owner_pubkey != transition.new_owner_pubkey {
769 return ChainStatus::Broken;
770 }
771 if verify_quorum(proposal, bundle, policy, revocation, now).is_err() {
772 return ChainStatus::Broken;
773 }
774 expected_epoch += 1;
775 }
776 ChainStatus::Verified
777}
778
779pub struct EmptyRevocation;
782
783impl ActorRevocationLookup for EmptyRevocation {
784 fn revoked_at(&self, _actor_id: &str) -> Option<&str> {
785 None
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use ed25519_dalek::Signer;
793
794 fn rotate_q(threshold: u32, actors: &[&str], current_owner_counts: bool) -> Quorum {
795 Quorum {
796 threshold,
797 eligible_actors: actors.iter().map(|s| (*s).to_string()).collect(),
798 current_owner_counts,
799 role_constraints: None,
800 timelock_hours: None,
801 }
802 }
803
804 fn good_draft() -> PolicyDraft {
805 PolicyDraft {
806 frontier_id: "vfr_deadbeefdeadbeef".to_string(),
807 owner_epoch: 0,
808 bootstrap_epoch: 0,
809 rotate_quorum: rotate_q(
810 1,
811 &["reviewer:alice"],
812 true, ),
814 emergency_quorum: None,
815 policy_update_quorum: None,
816 attestation_ttl_hours: 168,
817 created_at: "2026-05-10T00:00:00+00:00".to_string(),
818 }
819 }
820
821 #[test]
822 fn from_draft_derives_policy_id() {
823 let policy = GovernancePolicy::from_draft(good_draft()).unwrap();
824 assert!(policy.policy_id.starts_with("vgp_"));
825 assert_eq!(policy.policy_id.len(), 20); policy.verify_content_address().unwrap();
827 }
828
829 #[test]
830 fn policy_id_deterministic_over_same_body() {
831 let a = GovernancePolicy::from_draft(good_draft()).unwrap();
832 let b = GovernancePolicy::from_draft(good_draft()).unwrap();
833 assert_eq!(a.policy_id, b.policy_id);
834 }
835
836 #[test]
837 fn policy_id_differs_when_threshold_differs() {
838 let mut draft = good_draft();
839 draft.rotate_quorum = rotate_q(2, &["reviewer:alice", "reviewer:bob"], true);
840 let a = GovernancePolicy::from_draft(draft).unwrap();
841 let b = GovernancePolicy::from_draft(good_draft()).unwrap();
842 assert_ne!(a.policy_id, b.policy_id);
843 }
844
845 #[test]
846 fn duplicate_eligible_actor_rejected() {
847 let mut draft = good_draft();
848 draft.rotate_quorum = rotate_q(2, &["reviewer:alice", "reviewer:alice"], true);
849 let err = GovernancePolicy::from_draft(draft).unwrap_err();
850 assert!(
851 err.contains("duplicate"),
852 "expected duplicate error, got: {err}"
853 );
854 }
855
856 #[test]
857 fn threshold_above_eligible_count_rejected() {
858 let mut draft = good_draft();
859 draft.rotate_quorum = rotate_q(5, &["reviewer:alice"], true);
860 let err = GovernancePolicy::from_draft(draft).unwrap_err();
861 assert!(
862 err.contains("cannot exceed"),
863 "expected threshold/count error, got: {err}"
864 );
865 }
866
867 #[test]
868 fn non_bootstrap_current_owner_counts_rejected() {
869 let mut draft = good_draft();
870 draft.bootstrap_epoch = 0;
871 draft.owner_epoch = 1; draft.rotate_quorum = rotate_q(1, &["reviewer:alice"], true);
873 let err = GovernancePolicy::from_draft(draft).unwrap_err();
874 assert!(
875 err.contains("bootstrap"),
876 "expected bootstrap-only error, got: {err}"
877 );
878 }
879
880 #[test]
881 fn policy_update_quorum_below_rotate_quorum_rejected() {
882 let mut draft = good_draft();
883 draft.owner_epoch = 1; draft.rotate_quorum = rotate_q(3, &["a", "b", "c", "d"], false);
885 draft.policy_update_quorum = Some(rotate_q(2, &["a", "b", "c", "d"], false));
886 let err = GovernancePolicy::from_draft(draft).unwrap_err();
887 assert!(
888 err.contains("policy_update_quorum"),
889 "expected policy-update floor error, got: {err}"
890 );
891 }
892
893 fn fresh_keypair() -> (ed25519_dalek::SigningKey, String) {
896 use rand::rngs::OsRng;
897 let sk = ed25519_dalek::SigningKey::generate(&mut OsRng);
898 let pk_hex = hex::encode(sk.verifying_key().to_bytes());
899 (sk, pk_hex)
900 }
901
902 fn build_test_policy(
903 threshold: u32,
904 actors: &[&str],
905 owner_epoch: u64,
906 current_owner_counts: bool,
907 bootstrap: bool,
908 ) -> GovernancePolicy {
909 GovernancePolicy::from_draft(PolicyDraft {
910 frontier_id: "vfr_test123".to_string(),
911 owner_epoch,
912 bootstrap_epoch: if bootstrap { 0 } else { owner_epoch },
913 rotate_quorum: rotate_q(threshold, actors, current_owner_counts),
914 emergency_quorum: None,
915 policy_update_quorum: None,
916 attestation_ttl_hours: 168,
917 created_at: "2026-05-10T00:00:00+00:00".to_string(),
918 })
919 .unwrap()
920 }
921
922 fn build_test_proposal(policy: &GovernancePolicy, target_epoch: u64) -> OwnerRotateProposal {
923 OwnerRotateProposal::from_draft(ProposalDraft {
924 frontier_id: policy.frontier_id.clone(),
925 old_owner_actor_id: "owner:current".to_string(),
926 old_owner_pubkey: "00".repeat(32),
927 new_owner_actor_id: "owner:new".to_string(),
928 new_owner_pubkey: "11".repeat(32),
929 owner_epoch: target_epoch,
930 previous_registry_entry_hash: format!("sha256:{}", "0".repeat(64)),
931 governance_policy_id: policy.policy_id.clone(),
932 reason: "test rotation".to_string(),
933 created_at: "2026-05-10T00:00:00+00:00".to_string(),
934 expires_at: "2099-01-01T00:00:00+00:00".to_string(),
935 nonce: "deadbeef".to_string(),
936 })
937 .unwrap()
938 }
939
940 fn sign_attestation(
941 proposal: &OwnerRotateProposal,
942 attester_id: &str,
943 sk: &ed25519_dalek::SigningKey,
944 ) -> AttestationEntry {
945 let preimage = proposal.preimage_bytes().unwrap();
946 let sig = sk.sign(&preimage);
947 AttestationEntry {
948 attester_id: attester_id.to_string(),
949 attester_pubkey: hex::encode(sk.verifying_key().to_bytes()),
950 judgment: "approve_owner_rotate".to_string(),
951 signature: hex::encode(sig.to_bytes()),
952 signed_at: "2026-05-10T01:00:00+00:00".to_string(),
953 }
954 }
955
956 #[test]
957 fn quorum_succeeds_when_threshold_met() {
958 let (sk_a, _) = fresh_keypair();
959 let (sk_b, _) = fresh_keypair();
960 let policy = build_test_policy(2, &["reviewer:alice", "reviewer:bob"], 0, false, true);
961 let proposal = build_test_proposal(&policy, 1);
963 let bundle = OwnerRotateAttestationBundle::new(
964 &proposal,
965 vec![
966 sign_attestation(&proposal, "reviewer:alice", &sk_a),
967 sign_attestation(&proposal, "reviewer:bob", &sk_b),
968 ],
969 )
970 .unwrap();
971 let report = verify_quorum(
972 &proposal,
973 &bundle,
974 &policy,
975 &EmptyRevocation,
976 "2026-05-10T02:00:00+00:00",
977 )
978 .unwrap();
979 assert_eq!(report.threshold, 2);
980 assert_eq!(report.approving_signers.len(), 2);
981 }
982
983 #[test]
984 fn quorum_fails_when_threshold_not_met() {
985 let (sk_a, _) = fresh_keypair();
986 let policy = build_test_policy(2, &["reviewer:alice", "reviewer:bob"], 0, false, true);
987 let proposal = build_test_proposal(&policy, 1);
988 let bundle = OwnerRotateAttestationBundle::new(
989 &proposal,
990 vec![sign_attestation(&proposal, "reviewer:alice", &sk_a)],
991 )
992 .unwrap();
993 let err = verify_quorum(
994 &proposal,
995 &bundle,
996 &policy,
997 &EmptyRevocation,
998 "2026-05-10T02:00:00+00:00",
999 )
1000 .unwrap_err();
1001 assert!(err.contains("quorum not met"), "got: {err}");
1002 }
1003
1004 #[test]
1005 fn duplicate_attester_counted_once() {
1006 let (sk_a, _) = fresh_keypair();
1007 let policy = build_test_policy(2, &["reviewer:alice", "reviewer:bob"], 0, false, true);
1008 let proposal = build_test_proposal(&policy, 1);
1009 let bundle = OwnerRotateAttestationBundle::new(
1011 &proposal,
1012 vec![
1013 sign_attestation(&proposal, "reviewer:alice", &sk_a),
1014 sign_attestation(&proposal, "reviewer:alice", &sk_a),
1015 ],
1016 )
1017 .unwrap();
1018 let err = verify_quorum(
1019 &proposal,
1020 &bundle,
1021 &policy,
1022 &EmptyRevocation,
1023 "2026-05-10T02:00:00+00:00",
1024 )
1025 .unwrap_err();
1026 assert!(err.contains("quorum not met"), "got: {err}");
1027 }
1028
1029 #[test]
1030 fn ineligible_attester_rejected() {
1031 let (sk_x, _) = fresh_keypair();
1032 let policy = build_test_policy(1, &["reviewer:alice"], 0, false, true);
1033 let proposal = build_test_proposal(&policy, 1);
1034 let bundle = OwnerRotateAttestationBundle::new(
1035 &proposal,
1036 vec![sign_attestation(&proposal, "reviewer:not-in-list", &sk_x)],
1037 )
1038 .unwrap();
1039 let err = verify_quorum(
1040 &proposal,
1041 &bundle,
1042 &policy,
1043 &EmptyRevocation,
1044 "2026-05-10T02:00:00+00:00",
1045 )
1046 .unwrap_err();
1047 assert!(err.contains("not in"), "got: {err}");
1048 }
1049
1050 #[test]
1051 fn wrong_signature_rejected() {
1052 let (sk_a, _) = fresh_keypair();
1053 let (sk_other, _) = fresh_keypair();
1054 let policy = build_test_policy(1, &["reviewer:alice"], 0, false, true);
1055 let proposal = build_test_proposal(&policy, 1);
1056 let mut entry = sign_attestation(&proposal, "reviewer:alice", &sk_a);
1057 let bogus = sk_other.sign(b"unrelated");
1061 entry.signature = hex::encode(bogus.to_bytes());
1062 let bundle = OwnerRotateAttestationBundle::new(&proposal, vec![entry]).unwrap();
1063 let err = verify_quorum(
1064 &proposal,
1065 &bundle,
1066 &policy,
1067 &EmptyRevocation,
1068 "2026-05-10T02:00:00+00:00",
1069 )
1070 .unwrap_err();
1071 assert!(err.contains("does not verify"), "got: {err}");
1072 }
1073
1074 struct OneRevoked {
1075 actor: String,
1076 at: String,
1077 }
1078
1079 impl ActorRevocationLookup for OneRevoked {
1080 fn revoked_at(&self, actor_id: &str) -> Option<&str> {
1081 if actor_id == self.actor {
1082 Some(&self.at)
1083 } else {
1084 None
1085 }
1086 }
1087 }
1088
1089 #[test]
1090 fn revoked_attester_rejected() {
1091 let (sk_a, _) = fresh_keypair();
1092 let policy = build_test_policy(1, &["reviewer:alice"], 0, false, true);
1093 let proposal = build_test_proposal(&policy, 1);
1094 let bundle = OwnerRotateAttestationBundle::new(
1095 &proposal,
1096 vec![sign_attestation(&proposal, "reviewer:alice", &sk_a)],
1097 )
1098 .unwrap();
1099 let revoked_lookup = OneRevoked {
1100 actor: "reviewer:alice".to_string(),
1101 at: "2026-05-10T00:30:00+00:00".to_string(),
1102 };
1103 let err = verify_quorum(
1104 &proposal,
1105 &bundle,
1106 &policy,
1107 &revoked_lookup,
1108 "2026-05-10T02:00:00+00:00",
1109 )
1110 .unwrap_err();
1111 assert!(err.contains("revoked"), "got: {err}");
1112 }
1113
1114 #[test]
1115 fn expired_proposal_rejected() {
1116 let (sk_a, _) = fresh_keypair();
1117 let policy = build_test_policy(1, &["reviewer:alice"], 0, false, true);
1118 let mut proposal = build_test_proposal(&policy, 1);
1119 proposal.expires_at = "2026-05-09T00:00:00+00:00".to_string();
1120 proposal.proposal_id = proposal.derive_id().unwrap();
1121 let bundle = OwnerRotateAttestationBundle::new(
1122 &proposal,
1123 vec![sign_attestation(&proposal, "reviewer:alice", &sk_a)],
1124 )
1125 .unwrap();
1126 let err = verify_quorum(
1127 &proposal,
1128 &bundle,
1129 &policy,
1130 &EmptyRevocation,
1131 "2026-05-10T02:00:00+00:00",
1132 )
1133 .unwrap_err();
1134 assert!(err.contains("expired"), "got: {err}");
1135 }
1136
1137 #[test]
1138 fn proposal_pinned_to_correct_epoch() {
1139 let (sk_a, _) = fresh_keypair();
1140 let policy = build_test_policy(1, &["reviewer:alice"], 0, false, true);
1141 let proposal = build_test_proposal(&policy, 3);
1144 let bundle = OwnerRotateAttestationBundle::new(
1145 &proposal,
1146 vec![sign_attestation(&proposal, "reviewer:alice", &sk_a)],
1147 )
1148 .unwrap();
1149 let err = verify_quorum(
1150 &proposal,
1151 &bundle,
1152 &policy,
1153 &EmptyRevocation,
1154 "2026-05-10T02:00:00+00:00",
1155 )
1156 .unwrap_err();
1157 assert!(err.contains("must equal policy.owner_epoch"), "got: {err}");
1158 }
1159
1160 #[test]
1161 fn role_constraints_exceeding_threshold_rejected() {
1162 let mut draft = good_draft();
1163 draft.owner_epoch = 1;
1164 draft.rotate_quorum = Quorum {
1165 threshold: 2,
1166 eligible_actors: vec!["a".into(), "b".into(), "c".into()],
1167 current_owner_counts: false,
1168 role_constraints: Some(RoleConstraints {
1169 min_domain_maintainers: Some(2),
1170 min_registry_stewards: Some(2),
1171 min_independent_stewards: None,
1172 }),
1173 timelock_hours: None,
1174 };
1175 let err = GovernancePolicy::from_draft(draft).unwrap_err();
1176 assert!(
1177 err.contains("role_constraints"),
1178 "expected role-constraint error, got: {err}"
1179 );
1180 }
1181}