Skip to main content

exo_gatekeeper/
types.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Gatekeeper governance types.
18//!
19//! Types specific to the judicial branch that are not part of exo-core.
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use exo_core::Did;
24use serde::{Deserialize, Serialize};
25
26// ---------------------------------------------------------------------------
27// Permission & capability types
28// ---------------------------------------------------------------------------
29
30/// A named permission.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct Permission(pub String);
33
34impl Permission {
35    #[must_use]
36    pub fn new(value: impl Into<String>) -> Self {
37        Self(value.into())
38    }
39}
40
41/// A set of permissions.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
43pub struct PermissionSet {
44    pub permissions: Vec<Permission>,
45}
46
47impl PermissionSet {
48    #[must_use]
49    pub fn new(permissions: Vec<Permission>) -> Self {
50        Self { permissions }
51    }
52
53    pub fn contains(&self, p: &Permission) -> bool {
54        self.permissions.contains(p)
55    }
56
57    pub fn is_empty(&self) -> bool {
58        self.permissions.is_empty()
59    }
60}
61
62// ---------------------------------------------------------------------------
63// Government branches & roles
64// ---------------------------------------------------------------------------
65
66/// Branch of government.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
68pub enum GovernmentBranch {
69    Legislative,
70    Executive,
71    Judicial,
72}
73
74impl GovernmentBranch {
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::Legislative => "legislative",
79            Self::Executive => "executive",
80            Self::Judicial => "judicial",
81        }
82    }
83}
84
85/// Governed role names recognized by the constitutional fabric.
86///
87/// The names are intentionally finite.  Adjudication may carry zero roles, but
88/// any supplied role must be one of these governed names and must match the
89/// branch assigned below.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum GovernedRoleName {
93    Senator,
94    Legislator,
95    Voter,
96    Executive,
97    ExecutiveAdmin,
98    Operator,
99    Worker,
100    Judge,
101    TransitionJudge,
102}
103
104impl GovernedRoleName {
105    #[must_use]
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::Senator => "senator",
109            Self::Legislator => "legislator",
110            Self::Voter => "voter",
111            Self::Executive => "executive",
112            Self::ExecutiveAdmin => "executive-admin",
113            Self::Operator => "operator",
114            Self::Worker => "worker",
115            Self::Judge => "judge",
116            Self::TransitionJudge => "transition-judge",
117        }
118    }
119
120    #[must_use]
121    pub const fn branch(self) -> GovernmentBranch {
122        match self {
123            Self::Senator | Self::Legislator | Self::Voter => GovernmentBranch::Legislative,
124            Self::Executive | Self::ExecutiveAdmin | Self::Operator | Self::Worker => {
125                GovernmentBranch::Executive
126            }
127            Self::Judge | Self::TransitionJudge => GovernmentBranch::Judicial,
128        }
129    }
130
131    #[must_use]
132    pub fn parse(value: &str) -> Option<Self> {
133        match value {
134            "senator" => Some(Self::Senator),
135            "legislator" => Some(Self::Legislator),
136            "voter" => Some(Self::Voter),
137            "executive" => Some(Self::Executive),
138            "executive-admin" => Some(Self::ExecutiveAdmin),
139            "operator" => Some(Self::Operator),
140            "worker" => Some(Self::Worker),
141            "judge" => Some(Self::Judge),
142            "transition-judge" => Some(Self::TransitionJudge),
143            _ => None,
144        }
145    }
146}
147
148/// Role validation failure with enough structured context for diagnostics.
149#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
150pub enum RoleValidationError {
151    #[error("unknown governed role name")]
152    UnknownName { name: String },
153    #[error(
154        "role name does not match governed branch: expected {expected_branch}, actual {actual_branch}"
155    )]
156    BranchMismatch {
157        name: String,
158        expected_branch: &'static str,
159        actual_branch: &'static str,
160    },
161}
162
163/// Role held by an actor in the constitutional fabric.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct Role {
166    pub name: String,
167    pub branch: GovernmentBranch,
168}
169
170impl Role {
171    #[must_use]
172    pub fn governed(name: GovernedRoleName) -> Self {
173        Self {
174            name: name.as_str().to_owned(),
175            branch: name.branch(),
176        }
177    }
178
179    /// Validate a role supplied from storage, API, MCP, or WASM context.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`RoleValidationError::UnknownName`] when `self.name` is not a
184    /// governed role name, or [`RoleValidationError::BranchMismatch`] when the
185    /// governed role belongs to a different branch than `self.branch`.
186    pub fn validate_governed(&self) -> Result<GovernedRoleName, RoleValidationError> {
187        let Some(governed_name) = GovernedRoleName::parse(&self.name) else {
188            return Err(RoleValidationError::UnknownName {
189                name: self.name.clone(),
190            });
191        };
192        let expected_branch = governed_name.branch();
193        if expected_branch != self.branch {
194            return Err(RoleValidationError::BranchMismatch {
195                name: self.name.clone(),
196                expected_branch: expected_branch.as_str(),
197                actual_branch: self.branch.as_str(),
198            });
199        }
200        Ok(governed_name)
201    }
202
203    /// Construct and validate a governed role from external string input.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`RoleValidationError`] if `name` is not governed or if it is
208    /// paired with the wrong branch.
209    pub fn try_new(
210        name: impl Into<String>,
211        branch: GovernmentBranch,
212    ) -> Result<Self, RoleValidationError> {
213        let role = Self {
214            name: name.into(),
215            branch,
216        };
217        role.validate_governed()?;
218        Ok(role)
219    }
220}
221
222// ---------------------------------------------------------------------------
223// Bailment state (gatekeeper view — simpler than BCTS lifecycle)
224// ---------------------------------------------------------------------------
225
226/// Canonical DAG DB writeback bailment scope.
227pub const DAGDB_WRITEBACK_SCOPE: &str = "dag-db:writeback";
228
229/// Whether an active bailment + consent exists for a data scope.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub enum BailmentState {
232    /// No bailment established.
233    None,
234    /// Active bailment with consent.
235    Active {
236        bailor: Did,
237        bailee: Did,
238        scope: String,
239    },
240    /// Bailment suspended.
241    Suspended { reason: String },
242    /// Bailment terminated.
243    Terminated,
244}
245
246impl BailmentState {
247    pub fn is_active(&self) -> bool {
248        matches!(self, BailmentState::Active { .. })
249    }
250
251    pub fn authorizes_writeback(&self, agent_did: &str) -> bool {
252        matches!(
253            self,
254            BailmentState::Active { bailee, scope, .. }
255                if bailee.as_str() == agent_did && scope == DAGDB_WRITEBACK_SCOPE
256        )
257    }
258}
259
260// ---------------------------------------------------------------------------
261// Consent record
262// ---------------------------------------------------------------------------
263
264/// A consent record for the gatekeeper.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ConsentRecord {
267    pub subject: Did,
268    pub granted_to: Did,
269    pub scope: String,
270    pub active: bool,
271}
272
273// ---------------------------------------------------------------------------
274// Authority chain
275// ---------------------------------------------------------------------------
276
277/// Authority chain — the delegation path from root to actor.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
279pub struct AuthorityChain {
280    pub links: Vec<AuthorityLink>,
281}
282
283impl AuthorityChain {
284    pub fn is_empty(&self) -> bool {
285        self.links.is_empty()
286    }
287
288    pub fn depth(&self) -> usize {
289        self.links.len()
290    }
291}
292
293/// A single link in an authority chain.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct AuthorityLink {
296    pub grantor: Did,
297    pub grantee: Did,
298    pub permissions: PermissionSet,
299    pub signature: Vec<u8>,
300    /// Ed25519 public key (32 bytes) of the grantor.
301    ///
302    /// `check_authority_chain_valid` requires this key and performs Ed25519
303    /// signature verification over the domain-separated canonical CBOR link
304    /// payload. Links without this key fail closed.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub grantor_public_key: Option<Vec<u8>>,
307}
308
309/// DID-resolved Ed25519 public keys trusted by the runtime context.
310///
311/// Authority links still carry the key used for signature verification, but
312/// the invariant engine only accepts that key when it matches independently
313/// resolved key material for the claimed grantor DID.
314pub type TrustedAuthorityKeys = BTreeMap<Did, Vec<Vec<u8>>>;
315
316/// DID-resolved Ed25519 public keys trusted for actor provenance.
317///
318/// Provenance objects still carry the key used for signature verification, but
319/// the invariant engine only accepts that key when it matches independently
320/// resolved key material for the claimed actor DID.
321pub type TrustedProvenanceKeys = BTreeMap<Did, Vec<Vec<u8>>>;
322
323// ---------------------------------------------------------------------------
324// Quorum evidence
325// ---------------------------------------------------------------------------
326
327/// Quorum decision evidence.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct QuorumEvidence {
330    pub threshold: u32,
331    pub votes: Vec<QuorumVote>,
332}
333
334impl QuorumEvidence {
335    /// Returns `true` if the raw approval count (all votes, regardless of
336    /// provenance) meets the threshold. Used for legacy/simple quorum checks.
337    pub fn is_met(&self) -> bool {
338        let Some(threshold) = usize::try_from(self.threshold).ok() else {
339            return false;
340        };
341        self.distinct_approved_voter_count() >= threshold
342    }
343
344    /// Returns `true` if the threshold is met counting only non-synthetic
345    /// approved votes with explicit human, independent, first-order provenance.
346    /// Missing, system, synthetic, coordinated, or derivative provenance is
347    /// excluded from the human count per CR-001 §8.3.
348    pub fn is_met_authentic(&self) -> bool {
349        let Some(threshold) = usize::try_from(self.threshold).ok() else {
350            return false;
351        };
352        self.distinct_authentic_approved_voter_count() >= threshold
353    }
354
355    /// Count of votes where provenance explicitly marks the voter as synthetic.
356    pub fn synthetic_vote_count(&self) -> usize {
357        self.votes
358            .iter()
359            .filter(|v| v.provenance.as_ref().is_some_and(|p| p.is_synthetic()))
360            .count()
361    }
362
363    /// Voter DIDs that appear more than once in the evidence.
364    #[must_use]
365    pub fn duplicate_voters(&self) -> BTreeSet<Did> {
366        let mut seen = BTreeSet::new();
367        let mut duplicates = BTreeSet::new();
368        for vote in &self.votes {
369            if !seen.insert(vote.voter.clone()) {
370                duplicates.insert(vote.voter.clone());
371            }
372        }
373        duplicates
374    }
375
376    /// Count distinct approved voter DIDs, regardless of provenance.
377    #[must_use]
378    pub fn distinct_approved_voter_count(&self) -> usize {
379        self.votes
380            .iter()
381            .filter(|vote| vote.approved)
382            .map(|vote| vote.voter.clone())
383            .collect::<BTreeSet<_>>()
384            .len()
385    }
386
387    /// Count distinct approved voter DIDs that are not synthetic.
388    #[must_use]
389    pub fn distinct_authentic_approved_voter_count(&self) -> usize {
390        self.votes
391            .iter()
392            .filter(|vote| {
393                vote.approved
394                    && vote
395                        .provenance
396                        .as_ref()
397                        .is_some_and(Provenance::is_authentic_human_quorum_voice)
398            })
399            .map(|vote| vote.voter.clone())
400            .collect::<BTreeSet<_>>()
401            .len()
402    }
403}
404
405/// A single quorum vote.
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct QuorumVote {
408    pub voter: Did,
409    pub approved: bool,
410    pub signature: Vec<u8>,
411    /// Optional provenance for this vote.
412    ///
413    /// When `voice_kind` is `Synthetic`, this vote SHALL NOT count as a
414    /// distinct human approval in quorum (CR-001 §8.3).
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub provenance: Option<Provenance>,
417}
418
419// ---------------------------------------------------------------------------
420// Provenance metadata — voice taxonomy (CR-001 §8.3)
421// ---------------------------------------------------------------------------
422
423/// Whether an actor is a human, synthetic (AI), or system process.
424///
425/// Used by governance surfaces accepting plural input to prevent synthetic
426/// voices from being counted as distinct humans (CR-001 §8.3).
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
428pub enum VoiceKind {
429    /// A natural human actor providing genuine review or approval.
430    Human,
431    /// A synthetic (AI-generated) opinion or action. SHALL NOT be counted as
432    /// a distinct human vote in any quorum or clearance computation.
433    Synthetic,
434    /// An automated system process (not human or AI opinion).
435    System,
436}
437
438/// Independence claim for a reviewer or voter.
439///
440/// Coordinated actors sharing common control SHALL NOT be double-counted in
441/// quorum (CR-001 §8.3).
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
443pub enum IndependenceClaim {
444    /// Actor claims independent judgment with no undisclosed common control.
445    Independent,
446    /// Actor discloses coordination with another entity.
447    Coordinated,
448}
449
450/// Order of review for a governance opinion.
451///
452/// Derivative or echoed reviews SHALL NOT be counted equivalently to
453/// first-order independent review (CR-001 §8.3).
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
455pub enum ReviewOrder {
456    /// Direct, first-hand review of the original subject.
457    FirstOrder,
458    /// Derivative review — based on another review, summary, or echo.
459    Derivative,
460}
461
462// ---------------------------------------------------------------------------
463// Provenance metadata
464// ---------------------------------------------------------------------------
465
466/// Provenance metadata for an action.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468pub struct Provenance {
469    pub actor: Did,
470    pub timestamp: String,
471    pub action_hash: Vec<u8>,
472    pub signature: Vec<u8>,
473    /// Ed25519 public key (32 bytes) of the actor.
474    ///
475    /// `check_provenance_verifiable` requires this key and performs Ed25519
476    /// signature verification over the domain-separated canonical CBOR
477    /// provenance payload. Provenance without this key fails closed.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub public_key: Option<Vec<u8>>,
480    /// Whether this actor is human, synthetic (AI), or a system process.
481    ///
482    /// Governance surfaces accepting plural input MUST use this field to
483    /// prevent synthetic voices from counting as distinct humans (CR-001 §8.3).
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub voice_kind: Option<VoiceKind>,
486    /// Whether the actor claims to act independently (no undisclosed common
487    /// control with other reviewers or voters).
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub independence: Option<IndependenceClaim>,
490    /// Whether this is a first-order review or derivative (echo/summary).
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub review_order: Option<ReviewOrder>,
493}
494
495impl Provenance {
496    pub fn is_signed(&self) -> bool {
497        !self.signature.is_empty()
498    }
499
500    /// Returns `true` when provenance explicitly identifies this as a human
501    /// voice. `None` (unspecified) returns `false` — unattributed provenance
502    /// is never assumed to be authentic human judgment.
503    pub fn is_human_voice(&self) -> bool {
504        self.voice_kind == Some(VoiceKind::Human)
505    }
506
507    /// Returns `true` when provenance explicitly claims independence.
508    /// `None` (unspecified) is treated as non-independent.
509    pub fn is_independent(&self) -> bool {
510        self.independence == Some(IndependenceClaim::Independent)
511    }
512
513    /// Returns `true` when this provenance is explicitly first-order review.
514    /// `None` and derivative review are not equivalent to direct human review.
515    pub fn is_first_order_review(&self) -> bool {
516        self.review_order == Some(ReviewOrder::FirstOrder)
517    }
518
519    /// Returns `true` when all taxonomy fields required for a human quorum
520    /// claim are present and explicit. Cryptographic verification is performed
521    /// by the invariant engine because it needs trusted DID-resolved keys.
522    pub fn is_authentic_human_quorum_voice(&self) -> bool {
523        self.is_human_voice() && self.is_independent() && self.is_first_order_review()
524    }
525
526    /// Returns `true` when this is explicitly a synthetic (AI-generated) voice.
527    pub fn is_synthetic(&self) -> bool {
528        self.voice_kind == Some(VoiceKind::Synthetic)
529    }
530}
531
532// ===========================================================================
533// Tests
534// ===========================================================================
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn did(s: &str) -> Did {
541        Did::new(s).expect("valid DID")
542    }
543
544    #[test]
545    fn permission_set_contains() {
546        let set = PermissionSet::new(vec![Permission::new("read"), Permission::new("write")]);
547        assert!(set.contains(&Permission::new("read")));
548        assert!(!set.contains(&Permission::new("admin")));
549    }
550
551    #[test]
552    fn permission_set_empty() {
553        let set = PermissionSet::default();
554        assert!(set.is_empty());
555    }
556
557    #[test]
558    fn bailment_state_is_active() {
559        let active = BailmentState::Active {
560            bailor: did("did:exo:bailor"),
561            bailee: did("did:exo:bailee"),
562            scope: "data".into(),
563        };
564        assert!(active.is_active());
565        assert!(!BailmentState::None.is_active());
566        assert!(!BailmentState::Terminated.is_active());
567        let suspended = BailmentState::Suspended {
568            reason: "audit".into(),
569        };
570        assert!(!suspended.is_active());
571    }
572
573    #[test]
574    fn bailment_state_authorizes_writeback_for_active_bailee_and_scope() {
575        let active = BailmentState::Active {
576            bailor: did("did:exo:bailor"),
577            bailee: did("did:exo:bailee"),
578            scope: DAGDB_WRITEBACK_SCOPE.into(),
579        };
580
581        assert!(active.authorizes_writeback("did:exo:bailee"));
582    }
583
584    #[test]
585    fn bailment_state_authorizes_writeback_rejects_wrong_bailee() {
586        let active = BailmentState::Active {
587            bailor: did("did:exo:bailor"),
588            bailee: did("did:exo:bailee"),
589            scope: DAGDB_WRITEBACK_SCOPE.into(),
590        };
591
592        assert!(!active.authorizes_writeback("did:exo:other"));
593    }
594
595    #[test]
596    fn bailment_state_authorizes_writeback_rejects_wrong_scope() {
597        let active = BailmentState::Active {
598            bailor: did("did:exo:bailor"),
599            bailee: did("did:exo:bailee"),
600            scope: "dag-db:read".into(),
601        };
602
603        assert!(!active.authorizes_writeback("did:exo:bailee"));
604    }
605
606    #[test]
607    fn bailment_state_authorizes_writeback_rejects_inactive_states() {
608        assert!(!BailmentState::None.authorizes_writeback("did:exo:bailee"));
609        assert!(!BailmentState::Terminated.authorizes_writeback("did:exo:bailee"));
610        assert!(
611            !BailmentState::Suspended {
612                reason: "audit".into(),
613            }
614            .authorizes_writeback("did:exo:bailee")
615        );
616    }
617
618    #[test]
619    fn authority_chain_empty() {
620        let chain = AuthorityChain::default();
621        assert!(chain.is_empty());
622        assert_eq!(chain.depth(), 0);
623    }
624
625    #[test]
626    fn authority_chain_depth() {
627        let chain = AuthorityChain {
628            links: vec![
629                AuthorityLink {
630                    grantor: did("did:exo:root"),
631                    grantee: did("did:exo:mid"),
632                    permissions: PermissionSet::default(),
633                    signature: vec![1],
634                    grantor_public_key: None,
635                },
636                AuthorityLink {
637                    grantor: did("did:exo:mid"),
638                    grantee: did("did:exo:leaf"),
639                    permissions: PermissionSet::default(),
640                    signature: vec![2],
641                    grantor_public_key: None,
642                },
643            ],
644        };
645        assert_eq!(chain.depth(), 2);
646        assert!(!chain.is_empty());
647    }
648
649    fn make_vote(voter: &str, approved: bool, sig: u8, voice: Option<VoiceKind>) -> QuorumVote {
650        QuorumVote {
651            voter: did(voter),
652            approved,
653            signature: vec![sig],
654            provenance: voice.map(|vk| Provenance {
655                actor: did(voter),
656                timestamp: "t".into(),
657                action_hash: vec![1],
658                signature: vec![sig],
659                public_key: None,
660                voice_kind: Some(vk),
661                independence: (vk == VoiceKind::Human).then_some(IndependenceClaim::Independent),
662                review_order: (vk == VoiceKind::Human).then_some(ReviewOrder::FirstOrder),
663            }),
664        }
665    }
666
667    #[test]
668    fn quorum_evidence_met() {
669        let ev = QuorumEvidence {
670            threshold: 2,
671            votes: vec![
672                QuorumVote {
673                    voter: did("did:exo:v1"),
674                    approved: true,
675                    signature: vec![1],
676                    provenance: None,
677                },
678                QuorumVote {
679                    voter: did("did:exo:v2"),
680                    approved: true,
681                    signature: vec![2],
682                    provenance: None,
683                },
684                QuorumVote {
685                    voter: did("did:exo:v3"),
686                    approved: false,
687                    signature: vec![3],
688                    provenance: None,
689                },
690            ],
691        };
692        assert!(ev.is_met());
693    }
694
695    #[test]
696    fn quorum_evidence_not_met() {
697        let ev = QuorumEvidence {
698            threshold: 3,
699            votes: vec![
700                QuorumVote {
701                    voter: did("did:exo:v1"),
702                    approved: true,
703                    signature: vec![1],
704                    provenance: None,
705                },
706                QuorumVote {
707                    voter: did("did:exo:v2"),
708                    approved: false,
709                    signature: vec![2],
710                    provenance: None,
711                },
712            ],
713        };
714        assert!(!ev.is_met());
715    }
716
717    #[test]
718    fn quorum_evidence_counts_distinct_voters_only() {
719        let ev = QuorumEvidence {
720            threshold: 2,
721            votes: vec![
722                QuorumVote {
723                    voter: did("did:exo:v1"),
724                    approved: true,
725                    signature: vec![1],
726                    provenance: None,
727                },
728                QuorumVote {
729                    voter: did("did:exo:v1"),
730                    approved: true,
731                    signature: vec![2],
732                    provenance: None,
733                },
734            ],
735        };
736        assert!(
737            !ev.is_met(),
738            "duplicate voter DIDs must not inflate raw quorum evidence"
739        );
740    }
741
742    // ── CR-001 §8.3: authentic quorum counting ───────────────────────────────
743
744    #[test]
745    fn quorum_is_met_authentic_excludes_synthetic() {
746        // 2 humans approve, 1 synthetic approves — threshold 3 should fail authentic
747        let ev = QuorumEvidence {
748            threshold: 3,
749            votes: vec![
750                make_vote("did:exo:h1", true, 1, Some(VoiceKind::Human)),
751                make_vote("did:exo:h2", true, 2, Some(VoiceKind::Human)),
752                make_vote("did:exo:ai1", true, 3, Some(VoiceKind::Synthetic)),
753            ],
754        };
755        assert!(ev.is_met(), "raw count should pass (3 approvals)");
756        assert!(
757            !ev.is_met_authentic(),
758            "authentic count should fail (only 2 human)"
759        );
760        assert_eq!(ev.synthetic_vote_count(), 1);
761    }
762
763    #[test]
764    fn quorum_is_met_authentic_passes_all_human() {
765        let ev = QuorumEvidence {
766            threshold: 2,
767            votes: vec![
768                make_vote("did:exo:h1", true, 1, Some(VoiceKind::Human)),
769                make_vote("did:exo:h2", true, 2, Some(VoiceKind::Human)),
770            ],
771        };
772        assert!(ev.is_met_authentic());
773        assert_eq!(ev.synthetic_vote_count(), 0);
774    }
775
776    #[test]
777    fn quorum_is_met_authentic_counts_distinct_humans_only() {
778        let ev = QuorumEvidence {
779            threshold: 2,
780            votes: vec![
781                make_vote("did:exo:h1", true, 1, Some(VoiceKind::Human)),
782                make_vote("did:exo:h1", true, 2, Some(VoiceKind::Human)),
783            ],
784        };
785        assert!(
786            !ev.is_met_authentic(),
787            "duplicate human voter DIDs must not inflate authentic quorum evidence"
788        );
789    }
790
791    #[test]
792    fn quorum_is_met_authentic_rejects_legacy_votes_without_provenance() {
793        // Legacy votes (no provenance) are not authentic human quorum votes.
794        let ev = QuorumEvidence {
795            threshold: 2,
796            votes: vec![
797                QuorumVote {
798                    voter: did("did:exo:v1"),
799                    approved: true,
800                    signature: vec![1],
801                    provenance: None,
802                },
803                QuorumVote {
804                    voter: did("did:exo:v2"),
805                    approved: true,
806                    signature: vec![2],
807                    provenance: None,
808                },
809            ],
810        };
811        assert!(!ev.is_met_authentic());
812    }
813
814    #[test]
815    fn quorum_is_met_authentic_rejects_votes_without_human_provenance() {
816        let ev = QuorumEvidence {
817            threshold: 2,
818            votes: vec![
819                QuorumVote {
820                    voter: did("did:exo:v1"),
821                    approved: true,
822                    signature: vec![1],
823                    provenance: None,
824                },
825                make_vote("did:exo:system1", true, 2, Some(VoiceKind::System)),
826            ],
827        };
828
829        assert!(
830            !ev.is_met_authentic(),
831            "authentic quorum must not assume missing or system provenance is human"
832        );
833    }
834
835    #[test]
836    fn quorum_is_met_authentic_requires_independent_first_order_human_votes() {
837        let mut coordinated = make_vote("did:exo:h1", true, 1, Some(VoiceKind::Human));
838        coordinated
839            .provenance
840            .as_mut()
841            .expect("human provenance")
842            .independence = Some(IndependenceClaim::Coordinated);
843        coordinated
844            .provenance
845            .as_mut()
846            .expect("human provenance")
847            .review_order = Some(ReviewOrder::FirstOrder);
848
849        let mut derivative = make_vote("did:exo:h2", true, 2, Some(VoiceKind::Human));
850        derivative
851            .provenance
852            .as_mut()
853            .expect("human provenance")
854            .independence = Some(IndependenceClaim::Independent);
855        derivative
856            .provenance
857            .as_mut()
858            .expect("human provenance")
859            .review_order = Some(ReviewOrder::Derivative);
860
861        let ev = QuorumEvidence {
862            threshold: 2,
863            votes: vec![coordinated, derivative],
864        };
865
866        assert!(
867            !ev.is_met_authentic(),
868            "coordinated or derivative human claims must not count as authentic quorum"
869        );
870    }
871
872    // ── Provenance voice/independence helpers ────────────────────────────────
873
874    #[test]
875    fn provenance_is_human_voice() {
876        let human_prov = Provenance {
877            actor: did("did:exo:h1"),
878            timestamp: "t".into(),
879            action_hash: vec![1],
880            signature: vec![1],
881            public_key: None,
882            voice_kind: Some(VoiceKind::Human),
883            independence: Some(IndependenceClaim::Independent),
884            review_order: Some(ReviewOrder::FirstOrder),
885        };
886        assert!(human_prov.is_human_voice());
887        assert!(human_prov.is_independent());
888        assert!(human_prov.is_first_order_review());
889        assert!(human_prov.is_authentic_human_quorum_voice());
890        assert!(!human_prov.is_synthetic());
891    }
892
893    #[test]
894    fn provenance_synthetic_not_human() {
895        let ai_prov = Provenance {
896            actor: did("did:exo:ai1"),
897            timestamp: "t".into(),
898            action_hash: vec![1],
899            signature: vec![1],
900            public_key: None,
901            voice_kind: Some(VoiceKind::Synthetic),
902            independence: None,
903            review_order: None,
904        };
905        assert!(!ai_prov.is_human_voice());
906        assert!(ai_prov.is_synthetic());
907        assert!(!ai_prov.is_independent());
908    }
909
910    #[test]
911    fn provenance_unspecified_voice_not_human() {
912        // Unattributed provenance is never assumed to be authentic human judgment
913        let prov = Provenance {
914            actor: did("did:exo:unknown"),
915            timestamp: "t".into(),
916            action_hash: vec![1],
917            signature: vec![1],
918            public_key: None,
919            voice_kind: None,
920            independence: None,
921            review_order: None,
922        };
923        assert!(!prov.is_human_voice());
924        assert!(!prov.is_synthetic());
925        assert!(!prov.is_independent());
926    }
927
928    #[test]
929    fn provenance_is_signed() {
930        let signed = Provenance {
931            actor: did("did:exo:actor"),
932            timestamp: "2025-01-01".into(),
933            action_hash: vec![1],
934            signature: vec![4, 5, 6],
935            public_key: None,
936            voice_kind: None,
937            independence: None,
938            review_order: None,
939        };
940        assert!(signed.is_signed());
941
942        let unsigned = Provenance {
943            actor: did("did:exo:actor"),
944            timestamp: "2025-01-01".into(),
945            action_hash: vec![1],
946            signature: vec![],
947            public_key: None,
948            voice_kind: None,
949            independence: None,
950            review_order: None,
951        };
952        assert!(!unsigned.is_signed());
953    }
954}