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 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 #[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 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}