Skip to main content

chio_kernel/
threshold_approval.rs

1use chio_core::capability::governance::{
2    GovernedApprovalDecision, GovernedApprovalToken, ThresholdApprovalProposal,
3};
4use chio_core::capability::threshold_approval::ThresholdApprovalRequirement;
5use chio_core::crypto::PublicKey;
6use std::collections::HashMap;
7use std::sync::{Arc, RwLock};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ResolvedApproverIdentity {
11    pub identifier: String,
12    pub public_key: PublicKey,
13    pub directory_version: String,
14}
15
16pub trait ApproverDirectory: Send + Sync {
17    fn resolve_approver(&self, identifier: &str) -> Result<ResolvedApproverIdentity, String>;
18}
19
20pub trait ThresholdApprovalRequirementResolver: Send + Sync {
21    fn resolve_requirement(
22        &self,
23        policy_hash: &str,
24        server_id: &str,
25        tool_name: &str,
26    ) -> Result<Option<ThresholdApprovalRequirement>, String>;
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ThresholdApprovalCollectorState {
32    Collecting,
33    Ready,
34    Delivered,
35    Cancelled,
36}
37
38impl ThresholdApprovalCollectorState {
39    #[must_use]
40    pub fn is_terminal(self) -> bool {
41        matches!(self, Self::Delivered | Self::Cancelled)
42    }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46pub struct ThresholdApprovalCollectorProposal {
47    pub proposal: ThresholdApprovalProposal,
48    pub requirement: ThresholdApprovalRequirement,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub submitter: Option<PublicKey>,
51    #[serde(default)]
52    pub require_submitter_separation: bool,
53    pub state: ThresholdApprovalCollectorState,
54    pub tokens: Vec<GovernedApprovalToken>,
55    pub version: u64,
56    pub updated_at: u64,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub struct CollectedThresholdApprovalSet {
61    pub proposal: ThresholdApprovalProposal,
62    pub tokens: Vec<GovernedApprovalToken>,
63}
64
65#[derive(Debug, thiserror::Error)]
66pub enum ThresholdApprovalCollectorStoreError {
67    #[error("threshold approval proposal not found: {0}")]
68    NotFound(String),
69    #[error("threshold approval collector conflict: {0}")]
70    Conflict(String),
71    #[error("threshold approval collector backend error: {0}")]
72    Backend(String),
73    #[error("threshold approval collector serialization error: {0}")]
74    Serialization(String),
75}
76
77pub trait ThresholdApprovalCollectorStore: Send + Sync {
78    fn create(
79        &self,
80        proposal: &ThresholdApprovalCollectorProposal,
81    ) -> Result<(), ThresholdApprovalCollectorStoreError>;
82
83    fn get(
84        &self,
85        proposal_id: &str,
86    ) -> Result<Option<ThresholdApprovalCollectorProposal>, ThresholdApprovalCollectorStoreError>;
87
88    fn append_token(
89        &self,
90        proposal_id: &str,
91        expected_version: u64,
92        token: &GovernedApprovalToken,
93        replaced_token_id: Option<&str>,
94        next_state: ThresholdApprovalCollectorState,
95        updated_at: u64,
96    ) -> Result<ThresholdApprovalCollectorProposal, ThresholdApprovalCollectorStoreError>;
97
98    fn transition(
99        &self,
100        proposal_id: &str,
101        expected_version: u64,
102        next_state: ThresholdApprovalCollectorState,
103        updated_at: u64,
104    ) -> Result<ThresholdApprovalCollectorProposal, ThresholdApprovalCollectorStoreError>;
105}
106
107#[derive(Clone)]
108pub struct ThresholdApprovalCollector {
109    store: Arc<dyn ThresholdApprovalCollectorStore>,
110    active_policy_hash: String,
111    trusted_policy_authorities: Vec<PublicKey>,
112}
113
114impl std::fmt::Debug for ThresholdApprovalCollector {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("ThresholdApprovalCollector")
117            .field("active_policy_hash", &self.active_policy_hash)
118            .field(
119                "trusted_policy_authority_count",
120                &self.trusted_policy_authorities.len(),
121            )
122            .finish_non_exhaustive()
123    }
124}
125
126impl ThresholdApprovalCollector {
127    #[must_use]
128    pub fn new(
129        store: Arc<dyn ThresholdApprovalCollectorStore>,
130        active_policy_hash: String,
131        trusted_policy_authorities: Vec<PublicKey>,
132    ) -> Self {
133        Self {
134            store,
135            active_policy_hash,
136            trusted_policy_authorities,
137        }
138    }
139
140    pub fn create_proposal(
141        &self,
142        proposal: ThresholdApprovalProposal,
143        requirement: ThresholdApprovalRequirement,
144        submitter: Option<PublicKey>,
145        require_submitter_separation: bool,
146        now: u64,
147    ) -> Result<ThresholdApprovalCollectorProposal, ThresholdApprovalCollectorStoreError> {
148        requirement
149            .validate()
150            .map_err(ThresholdApprovalCollectorStoreError::Conflict)?;
151        proposal
152            .validate_at(now)
153            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?;
154        if proposal.body.policy_hash != self.active_policy_hash
155            || requirement.policy_hash != self.active_policy_hash
156        {
157            return Err(ThresholdApprovalCollectorStoreError::Conflict(
158                "threshold approval proposal is stale for the active policy".to_string(),
159            ));
160        }
161        if proposal.body.threshold != requirement.threshold
162            || proposal.body.eligible_set_digest != requirement.eligible_set_digest
163        {
164            return Err(ThresholdApprovalCollectorStoreError::Conflict(
165                "threshold approval proposal does not match its approver requirement".to_string(),
166            ));
167        }
168        if !self
169            .trusted_policy_authorities
170            .contains(&proposal.body.policy_authority)
171        {
172            return Err(ThresholdApprovalCollectorStoreError::Conflict(
173                "threshold approval proposal signer is not trusted".to_string(),
174            ));
175        }
176        if !proposal
177            .verify_signature()
178            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?
179        {
180            return Err(ThresholdApprovalCollectorStoreError::Conflict(
181                "threshold approval proposal signature did not verify".to_string(),
182            ));
183        }
184        let record = ThresholdApprovalCollectorProposal {
185            proposal,
186            requirement,
187            submitter,
188            require_submitter_separation,
189            state: ThresholdApprovalCollectorState::Collecting,
190            tokens: Vec::new(),
191            version: 0,
192            updated_at: now,
193        };
194        self.store.create(&record)?;
195        Ok(record)
196    }
197
198    pub fn get_proposal(
199        &self,
200        proposal_id: &str,
201    ) -> Result<Option<ThresholdApprovalCollectorProposal>, ThresholdApprovalCollectorStoreError>
202    {
203        self.store.get(proposal_id)
204    }
205
206    pub fn submit_token(
207        &self,
208        proposal_id: &str,
209        token: GovernedApprovalToken,
210        now: u64,
211    ) -> Result<ThresholdApprovalCollectorProposal, ThresholdApprovalCollectorStoreError> {
212        let record = self
213            .store
214            .get(proposal_id)?
215            .ok_or_else(|| ThresholdApprovalCollectorStoreError::NotFound(proposal_id.into()))?;
216        if record.state.is_terminal() {
217            return Err(ThresholdApprovalCollectorStoreError::Conflict(
218                "threshold approval proposal no longer accepts updates".to_string(),
219            ));
220        }
221        let proposal = &record.proposal;
222        proposal
223            .validate_at(now)
224            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?;
225        if proposal.body.policy_hash != self.active_policy_hash {
226            return Err(ThresholdApprovalCollectorStoreError::Conflict(
227                "threshold approval proposal is stale for the active policy".to_string(),
228            ));
229        }
230        let proposal_hash = proposal
231            .artifact_digest()
232            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?;
233        if token.id.is_empty()
234            || token.id.trim() != token.id
235            || token.request_id != proposal.body.request_id
236            || token.governed_intent_hash != proposal.body.governed_intent_hash
237            || token.subject != proposal.body.subject
238            || token.threshold_proposal_hash.as_deref() != Some(proposal_hash.as_str())
239            || token.decision != GovernedApprovalDecision::Approved
240            || token.issued_at < proposal.body.proposal_created_at
241            || token.issued_at >= proposal.body.proposal_deadline
242            || token.expires_at > proposal.body.proposal_deadline
243        {
244            return Err(ThresholdApprovalCollectorStoreError::Conflict(
245                "threshold approval token does not match the signed proposal".to_string(),
246            ));
247        }
248        token
249            .validate_time(now)
250            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?;
251        if !record
252            .requirement
253            .eligible_approvers
254            .iter()
255            .any(|eligible| eligible.public_key == token.approver)
256        {
257            return Err(ThresholdApprovalCollectorStoreError::Conflict(
258                "threshold approval token signer is not eligible".to_string(),
259            ));
260        }
261        if record.require_submitter_separation && record.submitter.as_ref() == Some(&token.approver)
262        {
263            return Err(ThresholdApprovalCollectorStoreError::Conflict(
264                "threshold approval submitter cannot approve their own proposal".to_string(),
265            ));
266        }
267        if !token
268            .verify_signature()
269            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?
270        {
271            return Err(ThresholdApprovalCollectorStoreError::Conflict(
272                "threshold approval token signature did not verify".to_string(),
273            ));
274        }
275        let digest = token
276            .artifact_digest()
277            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?;
278        let mut replaced_token_id = None;
279        for existing in &record.tokens {
280            let existing_digest = existing.artifact_digest().map_err(|error| {
281                ThresholdApprovalCollectorStoreError::Serialization(error.to_string())
282            })?;
283            if existing.id == token.id || existing_digest == digest {
284                return Err(ThresholdApprovalCollectorStoreError::Conflict(
285                    "threshold approval token id, digest, and signer must be unique".to_string(),
286                ));
287            }
288            if existing.approver == token.approver {
289                if existing.validate_time(now).is_ok() {
290                    return Err(ThresholdApprovalCollectorStoreError::Conflict(
291                        "threshold approval token id, digest, and signer must be unique"
292                            .to_string(),
293                    ));
294                }
295                replaced_token_id = Some(existing.id.as_str());
296            }
297        }
298        let active_count = record
299            .tokens
300            .iter()
301            .filter(|existing| {
302                Some(existing.id.as_str()) != replaced_token_id
303                    && existing.validate_time(now).is_ok()
304            })
305            .count()
306            .checked_add(1)
307            .ok_or_else(|| {
308                ThresholdApprovalCollectorStoreError::Conflict(
309                    "threshold approval token count overflowed".to_string(),
310                )
311            })?;
312        let threshold = usize::try_from(record.requirement.threshold).map_err(|_| {
313            ThresholdApprovalCollectorStoreError::Conflict(
314                "threshold approval quorum does not fit this platform".to_string(),
315            )
316        })?;
317        let next_state = if active_count >= threshold {
318            ThresholdApprovalCollectorState::Ready
319        } else {
320            ThresholdApprovalCollectorState::Collecting
321        };
322        self.store.append_token(
323            proposal_id,
324            record.version,
325            &token,
326            replaced_token_id,
327            next_state,
328            now,
329        )
330    }
331
332    pub fn deliver(
333        &self,
334        proposal_id: &str,
335        now: u64,
336    ) -> Result<CollectedThresholdApprovalSet, ThresholdApprovalCollectorStoreError> {
337        let record = self
338            .store
339            .get(proposal_id)?
340            .ok_or_else(|| ThresholdApprovalCollectorStoreError::NotFound(proposal_id.into()))?;
341        if record.state != ThresholdApprovalCollectorState::Ready {
342            return Err(ThresholdApprovalCollectorStoreError::Conflict(
343                "threshold approval proposal is not ready for delivery".to_string(),
344            ));
345        }
346        record
347            .proposal
348            .validate_at(now)
349            .map_err(|error| ThresholdApprovalCollectorStoreError::Conflict(error.to_string()))?;
350        if record.proposal.body.policy_hash != self.active_policy_hash {
351            return Err(ThresholdApprovalCollectorStoreError::Conflict(
352                "threshold approval proposal is stale for the active policy".to_string(),
353            ));
354        }
355        // A token may expire well before the proposal deadline. Ignore superseded
356        // or expired history and deliver only the currently valid set.
357        let valid_tokens = record
358            .tokens
359            .iter()
360            .filter(|token| token.validate_time(now).is_ok())
361            .cloned()
362            .collect::<Vec<_>>();
363        let threshold = usize::try_from(record.requirement.threshold).map_err(|_| {
364            ThresholdApprovalCollectorStoreError::Conflict(
365                "threshold approval quorum does not fit this platform".to_string(),
366            )
367        })?;
368        if valid_tokens.len() < threshold {
369            return Err(ThresholdApprovalCollectorStoreError::Conflict(
370                "threshold approval quorum is no longer satisfied".to_string(),
371            ));
372        }
373        let delivered = self.store.transition(
374            proposal_id,
375            record.version,
376            ThresholdApprovalCollectorState::Delivered,
377            now,
378        )?;
379        Ok(CollectedThresholdApprovalSet {
380            proposal: delivered.proposal,
381            tokens: valid_tokens,
382        })
383    }
384
385    pub fn cancel(
386        &self,
387        proposal_id: &str,
388        now: u64,
389    ) -> Result<ThresholdApprovalCollectorProposal, ThresholdApprovalCollectorStoreError> {
390        let record = self
391            .store
392            .get(proposal_id)?
393            .ok_or_else(|| ThresholdApprovalCollectorStoreError::NotFound(proposal_id.into()))?;
394        if record.state.is_terminal() {
395            return Err(ThresholdApprovalCollectorStoreError::Conflict(
396                "threshold approval proposal is already terminal".to_string(),
397            ));
398        }
399        self.store.transition(
400            proposal_id,
401            record.version,
402            ThresholdApprovalCollectorState::Cancelled,
403            now,
404        )
405    }
406}
407
408#[derive(Default)]
409pub struct InMemoryThresholdApprovalCollectorStore {
410    proposals: RwLock<HashMap<String, ThresholdApprovalCollectorProposal>>,
411}
412
413impl InMemoryThresholdApprovalCollectorStore {
414    #[must_use]
415    pub fn new() -> Self {
416        Self::default()
417    }
418}
419
420impl ThresholdApprovalCollectorStore for InMemoryThresholdApprovalCollectorStore {
421    fn create(
422        &self,
423        proposal: &ThresholdApprovalCollectorProposal,
424    ) -> Result<(), ThresholdApprovalCollectorStoreError> {
425        let mut proposals = self.proposals.write().map_err(|_| {
426            ThresholdApprovalCollectorStoreError::Backend("proposal map poisoned".to_string())
427        })?;
428        let id = &proposal.proposal.body.proposal_id;
429        match proposals.get(id) {
430            Some(existing) if existing == proposal => Ok(()),
431            Some(_) => Err(ThresholdApprovalCollectorStoreError::Conflict(
432                "proposal id already exists with different content".to_string(),
433            )),
434            None => {
435                proposals.insert(id.clone(), proposal.clone());
436                Ok(())
437            }
438        }
439    }
440
441    fn get(
442        &self,
443        proposal_id: &str,
444    ) -> Result<Option<ThresholdApprovalCollectorProposal>, ThresholdApprovalCollectorStoreError>
445    {
446        self.proposals
447            .read()
448            .map_err(|_| {
449                ThresholdApprovalCollectorStoreError::Backend("proposal map poisoned".to_string())
450            })
451            .map(|proposals| proposals.get(proposal_id).cloned())
452    }
453
454    fn append_token(
455        &self,
456        proposal_id: &str,
457        expected_version: u64,
458        token: &GovernedApprovalToken,
459        replaced_token_id: Option<&str>,
460        next_state: ThresholdApprovalCollectorState,
461        updated_at: u64,
462    ) -> Result<ThresholdApprovalCollectorProposal, ThresholdApprovalCollectorStoreError> {
463        let mut proposals = self.proposals.write().map_err(|_| {
464            ThresholdApprovalCollectorStoreError::Backend("proposal map poisoned".to_string())
465        })?;
466        let record = proposals.get_mut(proposal_id).ok_or_else(|| {
467            ThresholdApprovalCollectorStoreError::NotFound(proposal_id.to_string())
468        })?;
469        if record.version != expected_version || record.state.is_terminal() {
470            return Err(ThresholdApprovalCollectorStoreError::Conflict(
471                "threshold approval proposal changed concurrently".to_string(),
472            ));
473        }
474        if let Some(replaced_token_id) = replaced_token_id {
475            let existing = record
476                .tokens
477                .iter_mut()
478                .find(|existing| existing.id == replaced_token_id)
479                .ok_or_else(|| {
480                    ThresholdApprovalCollectorStoreError::Conflict(
481                        "threshold approval replacement token disappeared".to_string(),
482                    )
483                })?;
484            *existing = token.clone();
485        } else {
486            record.tokens.push(token.clone());
487        }
488        record.state = next_state;
489        record.version = record.version.checked_add(1).ok_or_else(|| {
490            ThresholdApprovalCollectorStoreError::Conflict("proposal version overflowed".into())
491        })?;
492        record.updated_at = updated_at;
493        Ok(record.clone())
494    }
495
496    fn transition(
497        &self,
498        proposal_id: &str,
499        expected_version: u64,
500        next_state: ThresholdApprovalCollectorState,
501        updated_at: u64,
502    ) -> Result<ThresholdApprovalCollectorProposal, ThresholdApprovalCollectorStoreError> {
503        let mut proposals = self.proposals.write().map_err(|_| {
504            ThresholdApprovalCollectorStoreError::Backend("proposal map poisoned".to_string())
505        })?;
506        let record = proposals.get_mut(proposal_id).ok_or_else(|| {
507            ThresholdApprovalCollectorStoreError::NotFound(proposal_id.to_string())
508        })?;
509        if record.version != expected_version || record.state.is_terminal() {
510            return Err(ThresholdApprovalCollectorStoreError::Conflict(
511                "threshold approval proposal changed concurrently".to_string(),
512            ));
513        }
514        record.state = next_state;
515        record.version = record.version.checked_add(1).ok_or_else(|| {
516            ThresholdApprovalCollectorStoreError::Conflict("proposal version overflowed".into())
517        })?;
518        record.updated_at = updated_at;
519        Ok(record.clone())
520    }
521}
522
523#[cfg(test)]
524#[allow(clippy::expect_used, clippy::unwrap_used)]
525mod tests {
526    use super::*;
527    use chio_core::capability::governance::{
528        GovernedApprovalTokenBody, ThresholdApprovalProposalBody,
529    };
530    use chio_core::capability::threshold_approval::ThresholdApproverIdentity;
531    use chio_core::crypto::{sha256_hex, Keypair};
532
533    struct Fixture {
534        collector: ThresholdApprovalCollector,
535        authority: Keypair,
536        submitter: Keypair,
537        alice: Keypair,
538        bob: Keypair,
539        subject: Keypair,
540        requirement: ThresholdApprovalRequirement,
541    }
542
543    fn fixture() -> Fixture {
544        let authority = Keypair::generate();
545        let submitter = Keypair::generate();
546        let alice = Keypair::generate();
547        let bob = Keypair::generate();
548        let subject = Keypair::generate();
549        let policy_hash = sha256_hex(b"active-policy");
550        let requirement = ThresholdApprovalRequirement::new(
551            policy_hash.clone(),
552            2,
553            vec![
554                ThresholdApproverIdentity {
555                    identifier: "alice".to_string(),
556                    public_key: alice.public_key(),
557                },
558                ThresholdApproverIdentity {
559                    identifier: "bob".to_string(),
560                    public_key: bob.public_key(),
561                },
562                ThresholdApproverIdentity {
563                    identifier: "submitter".to_string(),
564                    public_key: submitter.public_key(),
565                },
566            ],
567            "directory-v1".to_string(),
568            100,
569        )
570        .unwrap();
571        let collector = ThresholdApprovalCollector::new(
572            Arc::new(InMemoryThresholdApprovalCollectorStore::new()),
573            policy_hash,
574            vec![authority.public_key()],
575        );
576        Fixture {
577            collector,
578            authority,
579            submitter,
580            alice,
581            bob,
582            subject,
583            requirement,
584        }
585    }
586
587    fn proposal(fixture: &Fixture, proposal_id: &str) -> ThresholdApprovalProposal {
588        ThresholdApprovalProposal::sign(
589            ThresholdApprovalProposalBody {
590                schema: chio_core::capability::governance::THRESHOLD_APPROVAL_PROPOSAL_SCHEMA
591                    .to_string(),
592                proposal_id: proposal_id.to_string(),
593                request_id: "request-1".to_string(),
594                governed_intent_hash: sha256_hex(b"intent"),
595                subject: fixture.subject.public_key(),
596                authorizing_capability_digest: sha256_hex(b"capability"),
597                policy_hash: fixture.requirement.policy_hash.clone(),
598                threshold: fixture.requirement.threshold,
599                eligible_set_digest: fixture.requirement.eligible_set_digest.clone(),
600                proposal_created_at: 100,
601                proposal_deadline: 200,
602                policy_authority: fixture.authority.public_key(),
603            },
604            &fixture.authority,
605        )
606        .unwrap()
607    }
608
609    fn token(
610        proposal: &ThresholdApprovalProposal,
611        approver: &Keypair,
612        token_id: &str,
613    ) -> GovernedApprovalToken {
614        token_expiring_at(proposal, approver, token_id, 199)
615    }
616
617    fn token_expiring_at(
618        proposal: &ThresholdApprovalProposal,
619        approver: &Keypair,
620        token_id: &str,
621        expires_at: u64,
622    ) -> GovernedApprovalToken {
623        GovernedApprovalToken::sign(
624            GovernedApprovalTokenBody {
625                id: token_id.to_string(),
626                approver: approver.public_key(),
627                subject: proposal.body.subject.clone(),
628                governed_intent_hash: proposal.body.governed_intent_hash.clone(),
629                request_id: proposal.body.request_id.clone(),
630                threshold_proposal_hash: Some(proposal.artifact_digest().unwrap()),
631                issued_at: 101,
632                expires_at,
633                decision: GovernedApprovalDecision::Approved,
634            },
635            approver,
636        )
637        .unwrap()
638    }
639
640    #[test]
641    fn collector_persists_quorum_before_returning_original_tokens() {
642        let fixture = fixture();
643        let proposal = proposal(&fixture, "proposal-1");
644        fixture
645            .collector
646            .create_proposal(
647                proposal.clone(),
648                fixture.requirement.clone(),
649                Some(fixture.submitter.public_key()),
650                true,
651                100,
652            )
653            .unwrap();
654
655        let separated = fixture
656            .collector
657            .submit_token(
658                "proposal-1",
659                token(&proposal, &fixture.submitter, "token-submitter"),
660                110,
661            )
662            .unwrap_err();
663        assert!(separated.to_string().contains("cannot approve"));
664
665        let collecting = fixture
666            .collector
667            .submit_token(
668                "proposal-1",
669                token(&proposal, &fixture.alice, "token-alice"),
670                110,
671            )
672            .unwrap();
673        assert_eq!(
674            collecting.state,
675            ThresholdApprovalCollectorState::Collecting
676        );
677        let duplicate = fixture
678            .collector
679            .submit_token(
680                "proposal-1",
681                token(&proposal, &fixture.alice, "token-alice-2"),
682                111,
683            )
684            .unwrap_err();
685        assert!(duplicate.to_string().contains("must be unique"));
686
687        let ready = fixture
688            .collector
689            .submit_token(
690                "proposal-1",
691                token(&proposal, &fixture.bob, "token-bob"),
692                112,
693            )
694            .unwrap();
695        assert_eq!(ready.state, ThresholdApprovalCollectorState::Ready);
696        assert_eq!(ready.tokens.len(), 2);
697
698        let delivered = fixture.collector.deliver("proposal-1", 113).unwrap();
699        assert_eq!(delivered.proposal, proposal);
700        assert_eq!(
701            delivered
702                .tokens
703                .iter()
704                .map(|value| value.id.as_str())
705                .collect::<Vec<_>>(),
706            vec!["token-alice", "token-bob"]
707        );
708        let stored = fixture
709            .collector
710            .get_proposal("proposal-1")
711            .unwrap()
712            .unwrap();
713        assert_eq!(stored.state, ThresholdApprovalCollectorState::Delivered);
714        assert!(fixture.collector.deliver("proposal-1", 114).is_err());
715    }
716
717    #[test]
718    fn collector_rejects_stale_policy_changed_intent_and_terminal_updates() {
719        let fixture = fixture();
720        let mut stale_requirement = fixture.requirement.clone();
721        stale_requirement.policy_hash = sha256_hex(b"stale-policy");
722        assert!(fixture
723            .collector
724            .create_proposal(
725                proposal(&fixture, "stale"),
726                stale_requirement,
727                None,
728                false,
729                100,
730            )
731            .is_err());
732
733        let proposal = proposal(&fixture, "changed-intent");
734        fixture
735            .collector
736            .create_proposal(
737                proposal.clone(),
738                fixture.requirement.clone(),
739                None,
740                false,
741                100,
742            )
743            .unwrap();
744        let mut changed = token(&proposal, &fixture.alice, "changed-token");
745        changed.governed_intent_hash = sha256_hex(b"different-intent");
746        assert!(fixture
747            .collector
748            .submit_token("changed-intent", changed, 110)
749            .is_err());
750        fixture.collector.cancel("changed-intent", 111).unwrap();
751        assert!(fixture
752            .collector
753            .submit_token(
754                "changed-intent",
755                token(&proposal, &fixture.alice, "late-token"),
756                112,
757            )
758            .is_err());
759    }
760
761    // Token expiry is bounded above by the proposal deadline but not below, so a
762    // set that was quorate at submission can hold expired tokens by delivery time
763    // while the proposal itself is still valid.
764    #[test]
765    fn ready_proposal_accepts_replacement_for_an_expired_token() {
766        let fixture = fixture();
767        let proposal = proposal(&fixture, "expiring");
768        fixture
769            .collector
770            .create_proposal(
771                proposal.clone(),
772                fixture.requirement.clone(),
773                None,
774                false,
775                100,
776            )
777            .unwrap();
778
779        fixture
780            .collector
781            .submit_token(
782                "expiring",
783                token_expiring_at(&proposal, &fixture.alice, "token-alice", 120),
784                110,
785            )
786            .unwrap();
787        let ready = fixture
788            .collector
789            .submit_token(
790                "expiring",
791                token_expiring_at(&proposal, &fixture.bob, "token-bob", 199),
792                110,
793            )
794            .unwrap();
795        assert_eq!(ready.state, ThresholdApprovalCollectorState::Ready);
796
797        // The proposal deadline is 200, so only the tokens have lapsed here.
798        let expired = fixture.collector.deliver("expiring", 150).unwrap_err();
799        assert!(
800            expired
801                .to_string()
802                .contains("quorum is no longer satisfied"),
803            "delivery must reject lapsed tokens; got: {expired}"
804        );
805        let stored = fixture.collector.get_proposal("expiring").unwrap().unwrap();
806        assert_eq!(stored.state, ThresholdApprovalCollectorState::Ready);
807
808        let refreshed = fixture
809            .collector
810            .submit_token(
811                "expiring",
812                token_expiring_at(&proposal, &fixture.alice, "token-alice-fresh", 199),
813                150,
814            )
815            .unwrap();
816        assert_eq!(refreshed.state, ThresholdApprovalCollectorState::Ready);
817        assert_eq!(refreshed.tokens.len(), 2);
818        assert!(refreshed
819            .tokens
820            .iter()
821            .any(|token| token.id == "token-alice-fresh"));
822        assert!(!refreshed
823            .tokens
824            .iter()
825            .any(|token| token.id == "token-alice"));
826
827        let delivered = fixture.collector.deliver("expiring", 151).unwrap();
828        assert_eq!(delivered.tokens.len(), 2);
829        assert!(delivered
830            .tokens
831            .iter()
832            .any(|token| token.id == "token-alice-fresh"));
833    }
834}