Skip to main content

vela_protocol/
governance.rs

1//! v0.144: Registry governance policy. Per-frontier policy object
2//! declaring who can authorize owner rotation, what threshold of
3//! distinct attestations is required, and (during bootstrap only)
4//! whether the current registry owner alone can satisfy quorum.
5//!
6//! This cycle ships the data shape + content-addressing + validation
7//! rules. The binding to `vela registry owner-rotate` ships in v0.145.
8//!
9//! Schema: `vela.registry_governance_policy.v0.1`. Embedded JSON
10//! Schema lives at
11//! `crates/vela-protocol/embedded/carina-schemas/registry_governance_policy.schema.json`.
12//!
13//! Policy ids are content-addressed: `vgp_` + first 16 hex of
14//! sha256 over canonical bytes of the policy (with the
15//! `policy_id` and `valid_from_entry_hash` fields excluded from the
16//! preimage so the id remains derivable from policy contents alone).
17//!
18//! ## Scoping decisions baked into v0.144
19//!
20//! 1. **Bootstrap epoch**. `bootstrap_epoch = 0` policies MAY set
21//!    `current_owner_counts: true` (the only way a freshly published
22//!    frontier with no quorum yet can authorize its first rotation).
23//!    Any successor policy at `owner_epoch >= 1` MUST set
24//!    `current_owner_counts: false`; the validator rejects otherwise.
25//!
26//! 2. **Threshold floor**. Threshold MUST be >= 1 and MUST NOT exceed
27//!    the eligible-actors count.
28//!
29//! 3. **No duplicates**. Eligible actor ids must be unique within
30//!    each quorum's list. Duplicates would inflate quorum at
31//!    verification time (a single signer with two registered ids
32//!    cannot satisfy two slots).
33//!
34//! 4. **Policy update monotonicity**. The optional `policy_update_quorum`
35//!    threshold MUST be >= the `rotate_quorum` threshold so a
36//!    compromised owner cannot weaken governance via a unilateral
37//!    policy update.
38
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41
42/// Schema constant for the governance policy primitive.
43pub const POLICY_SCHEMA: &str = "vela.registry_governance_policy.v0.1";
44
45/// v0.145: schema constant for owner-rotate proposals.
46pub const OWNER_ROTATE_PROPOSAL_SCHEMA: &str = "vela.owner_rotate_proposal.v0.1";
47
48/// v0.145: schema constant for owner-rotate attestation bundles.
49pub const OWNER_ROTATE_BUNDLE_SCHEMA: &str = "vela.owner_rotate_attestation_bundle.v0.1";
50
51/// v0.146: schema constant for owner-epoch chain transcripts.
52pub const OWNER_EPOCH_CHAIN_SCHEMA: &str = "vela.owner_epoch_chain.v0.1";
53
54/// The full governance policy object. Serialized as
55/// `vela.registry_governance_policy.v0.1` JSON.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct GovernancePolicy {
58    pub schema: String,
59    /// Content-addressed policy id (`vgp_*`). Derived from the
60    /// policy body excluding `policy_id` and `valid_from_entry_hash`.
61    pub policy_id: String,
62    /// Frontier this policy governs.
63    pub frontier_id: String,
64    /// Owner epoch this policy is authoritative for.
65    pub owner_epoch: u64,
66    /// Epoch at which this policy was first content-addressed.
67    /// `bootstrap_epoch == owner_epoch` for bootstrap (genesis)
68    /// policies. The bootstrap relaxation
69    /// (`current_owner_counts: true`) is permitted only when
70    /// `bootstrap_epoch == 0` AND `owner_epoch == 0`.
71    pub bootstrap_epoch: u64,
72    /// Optional hash of the registry entry the policy first attaches
73    /// to. Populated when binding the policy; excluded from the
74    /// content-address preimage so the id remains derivable from
75    /// policy semantics alone.
76    #[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    /// How long a governance attestation remains valid after
84    /// signing. Default 168 hours (one week).
85    #[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/// One quorum specification: threshold + eligible signer ids +
95/// whether the current registry owner counts toward this quorum.
96#[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    /// Optional per-role minimum counts within the satisfying
102    /// quorum. Verification ignores roles not present in the
103    /// frontier's actor records; the v0.145 verifier reads roles
104    /// from the actor registry.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub role_constraints: Option<RoleConstraints>,
107    /// Timelock applied to actions authorized by this quorum (in
108    /// hours). Zero or absent means immediate. Used by emergency
109    /// and policy-update quorums; ignored for the standard rotate
110    /// quorum.
111    #[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/// Builder input: everything needed to construct a governance
126/// policy except its derived `policy_id`.
127#[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    /// Build a policy from a draft, deriving the content-addressed
141    /// `policy_id` from canonical bytes of the body. Validates the
142    /// policy and returns an error if any rule is violated.
143    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    /// Compute the content-addressed `vgp_*` id from the policy body.
163    /// Excludes `policy_id` and `valid_from_entry_hash` from the
164    /// preimage so the id is derivable from policy semantics alone.
165    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    /// Validate the policy against the v0.144 rules. Returns an
176    /// error string describing the first violation found.
177    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        // Bootstrap relaxation: current_owner_counts == true is
221        // permitted ONLY for bootstrap_epoch == 0 AND owner_epoch == 0
222        // policies. Any non-bootstrap policy that sets it must be
223        // rejected so a compromised current owner cannot ride a
224        // policy update to make themselves single-signer for
225        // rotation.
226        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    /// Re-derive the id and assert it matches the stored value.
240    /// Used by consumers loading a policy from disk or the wire.
241    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    // Reject duplicate eligible actor ids within the same quorum.
268    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// v0.145: owner-rotate proposal + attestation bundle + quorum verification.
293
294/// Content-addressed proposal binding a specific owner rotation.
295/// Governance attestations sign the canonical preimage of this
296/// object so they cannot be replayed against a different
297/// frontier, owner, key, policy, or registry entry.
298#[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/// Builder input for a proposal (everything except the derived id).
317#[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    /// Build a proposal from a draft, deriving the content-
335    /// addressed `vop_*` id from canonical bytes of the body.
336    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    /// Compute the content-addressed `vop_*` id over canonical bytes
367    /// of the body with `proposal_id` zeroed.
368    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    /// Canonical preimage bytes used as the message body for
378    /// governance attestation signatures. Excludes `proposal_id`
379    /// so the preimage is computed from semantics alone.
380    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    /// The `sha256:<hex>` string used as the
388    /// `proposal_preimage_hash` field in attestation bundles.
389    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/// Aggregate of detached signatures over a proposal's preimage.
397#[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    /// Build a bundle from a set of attestations, deriving the
417    /// content-addressed `vab_*` id from canonical bytes of the
418    /// body.
419    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
445/// Per-actor revocation lookup. The verifier needs to know whether
446/// an attester was revoked at the time they signed. v0.145 takes
447/// an inline closure so the substrate can plug in its actor record
448/// model without `governance.rs` depending on `project::Project`.
449pub trait ActorRevocationLookup {
450    fn revoked_at(&self, actor_id: &str) -> Option<&str>;
451}
452
453/// Result of a successful quorum verification.
454#[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
464/// Verify that an attestation bundle satisfies the policy's
465/// `rotate_quorum`. Returns `Ok(QuorumReport)` when quorum is met,
466/// or a human-readable error string naming the first violation.
467///
468/// Checks performed (in order):
469///
470/// 1. Schema constants match.
471/// 2. Bundle's `proposal_id` and `proposal_preimage_hash` match the
472///    proposal.
473/// 3. Proposal's `governance_policy_id` matches the policy.
474/// 4. Policy's `frontier_id` and `owner_epoch` match the proposal.
475/// 5. Each attestation's signature verifies against
476///    `proposal.preimage_bytes()` under the attester's pubkey.
477/// 6. Each attester is in the policy's `rotate_quorum.eligible_actors`
478///    (or is the current owner AND `current_owner_counts` is true).
479/// 7. Duplicate `attester_id` entries count once.
480/// 8. Each attester is not revoked at `signed_at` (per the lookup).
481/// 9. The number of unique approving signers meets the threshold.
482pub 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    // The policy must be the one governing the epoch *prior* to the
527    // proposed rotation. proposal.owner_epoch is the target epoch.
528    // policy.owner_epoch must equal proposal.owner_epoch - 1.
529    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    // Expiry check: now must be <= expires_at.
536    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    // Build eligibility set + lookup of attester -> pubkey policy
546    // expects. The actor registry on the frontier carries the
547    // authoritative pubkey per attester id; v0.145 takes the
548    // attester_pubkey from the bundle entry itself and refuses
549    // any pair whose attester_id is not in the eligible set.
550    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        // Eligibility check.
566        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        // Revocation check.
576        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        // Pubkey shape + signature check.
585        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        // Distinct-signer counting: same attester_id appearing twice
625        // counts once.
626        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// v0.146: owner epoch chain transcript + chain verification.
650
651#[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    /// Append a transition to the chain. Returns an error if the
680    /// owner_epoch is not strictly one greater than the last
681    /// transition (gaps and re-applies are rejected; the apply
682    /// step is the only writer).
683    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/// Status enum returned by `verify_chain`.
700#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
701#[serde(rename_all = "snake_case")]
702pub enum ChainStatus {
703    /// Chain has zero transitions (frontier is at owner_epoch 0,
704    /// running under the bootstrap policy with no governed
705    /// rotations yet).
706    Bootstrap,
707    /// Every transition verifies cleanly against its policy +
708    /// proposal + bundle inputs.
709    Verified,
710    /// The chain is missing or malformed; the consumer should
711    /// treat the entry as legacy (pre-v0.144) or refuse trust as
712    /// the audit posture dictates.
713    Legacy,
714    /// At least one transition failed verification. The error
715    /// string accompanies this status.
716    Broken,
717}
718
719/// Verify an entire owner-epoch chain. The verifier walks every
720/// transition and re-runs `verify_quorum` against the supplied
721/// policy/proposal/bundle maps. The maps are keyed by content-
722/// addressed id; missing keys produce `ChainStatus::Broken`.
723///
724/// Checks performed per transition:
725///
726/// - Transition `owner_epoch` is strictly one greater than the
727///   previous (chain starts at 1, no gaps, no rewinds).
728/// - `transition.policy_id == policy.policy_id`
729/// - `transition.proposal_id == proposal.proposal_id`
730/// - `transition.bundle_id == bundle.bundle_id`
731/// - `transition.previous_entry_hash == proposal.previous_registry_entry_hash`
732/// - `transition.new_owner_pubkey == proposal.new_owner_pubkey`
733/// - Full `verify_quorum` check succeeds for the transition.
734pub 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
779/// Empty `ActorRevocationLookup` impl for tests + scaffolds where
780/// no actor has been revoked.
781pub 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, // bootstrap: current owner counts
813            ),
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); // "vgp_" + 16 hex
826        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; // non-bootstrap
872        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; // non-bootstrap (so current_owner_counts must be false)
884        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    // --- v0.145 quorum verification tests ---
894
895    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        // Use a target epoch of 1 so policy.owner_epoch (0) + 1 == proposal.owner_epoch (1).
962        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        // alice signs twice; bundle should still fail to meet threshold=2.
1010        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        // Replace the signature with one from a different key (against
1058        // a different preimage; the signature itself is well-formed
1059        // but does not verify under attester_pubkey).
1060        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        // Target epoch 3 against a policy at epoch 0: should fail
1142        // (policy + 1 != proposal).
1143        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}