1use crate::types::{AgentId, ConversationId, IntentId};
2use laser_wire::framing::encode_named;
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Intent {
19 pub intent_id: IntentId,
21 pub conversation: ConversationId,
23 pub proposer: AgentId,
25 pub body: Vec<u8>,
27 pub digest: String,
29 pub eligible_voters: Vec<AgentId>,
31 pub mandatory_voters: Vec<AgentId>,
33 pub policy: IntentPolicy,
35 pub policy_version: u64,
37 pub deadline_micros: u64,
39 pub at_micros: u64,
41}
42
43#[bon::bon]
44impl Intent {
45 #[builder]
47 pub fn new(
48 conversation: ConversationId,
49 proposer: AgentId,
50 body: Vec<u8>,
51 eligible_voters: Vec<AgentId>,
52 #[builder(default)] mandatory_voters: Vec<AgentId>,
53 policy: IntentPolicy,
54 policy_version: u64,
55 deadline_micros: u64,
56 ) -> Result<Self, IntentError> {
57 let intent = Self {
58 intent_id: IntentId::new(),
59 conversation,
60 proposer,
61 digest: digest_of(&body),
62 body,
63 eligible_voters,
64 mandatory_voters,
65 policy,
66 policy_version,
67 deadline_micros,
68 at_micros: now_micros(),
69 };
70 intent.validate()?;
71 Ok(intent)
72 }
73
74 pub fn validate(&self) -> Result<(), IntentError> {
76 if self.eligible_voters.is_empty() {
77 return Err(IntentError::NoEligibleVoters);
78 }
79
80 let mut eligible = HashSet::with_capacity(self.eligible_voters.len());
81 for voter in &self.eligible_voters {
82 if !eligible.insert(voter.as_str()) {
83 return Err(IntentError::DuplicateEligibleVoter(voter.to_string()));
84 }
85 }
86
87 let mut mandatory = HashSet::with_capacity(self.mandatory_voters.len());
88 for voter in &self.mandatory_voters {
89 if !mandatory.insert(voter.as_str()) {
90 return Err(IntentError::DuplicateMandatoryVoter(voter.to_string()));
91 }
92 if !eligible.contains(voter.as_str()) {
93 return Err(IntentError::MandatoryVoterNotEligible(voter.to_string()));
94 }
95 }
96
97 if let IntentPolicy::AtLeast(required) = self.policy
98 && (required == 0 || required > self.eligible_voters.len())
99 {
100 return Err(IntentError::InvalidThreshold {
101 required,
102 eligible: self.eligible_voters.len(),
103 });
104 }
105 if self.deadline_micros <= self.at_micros {
106 return Err(IntentError::InvalidDeadline {
107 proposed: self.at_micros,
108 deadline: self.deadline_micros,
109 });
110 }
111 if self.digest != digest_of(&self.body) {
112 return Err(IntentError::DigestMismatch);
113 }
114 Ok(())
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum IntentError {
121 #[error("an intent requires at least one eligible voter")]
122 NoEligibleVoters,
123 #[error("eligible voter '{0}' appears more than once")]
124 DuplicateEligibleVoter(String),
125 #[error("mandatory voter '{0}' appears more than once")]
126 DuplicateMandatoryVoter(String),
127 #[error("mandatory voter '{0}' is not eligible")]
128 MandatoryVoterNotEligible(String),
129 #[error("threshold {required} is invalid for {eligible} eligible voters")]
130 InvalidThreshold { required: usize, eligible: usize },
131 #[error("deadline {deadline} must be after proposal time {proposed}")]
132 InvalidDeadline { proposed: u64, deadline: u64 },
133 #[error("intent digest does not match its body")]
134 DigestMismatch,
135 #[error("voter '{0}' is not eligible for this intent")]
136 IneligibleVoter(String),
137 #[error("decision is not bound to this intent body and policy version")]
138 DecisionIntentMismatch,
139}
140
141impl From<IntentError> for crate::error::LaserError {
146 fn from(error: IntentError) -> Self {
147 crate::error::LaserError::Invalid(error.to_string())
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum IntentPolicy {
155 All,
157 Any,
159 AtLeast(usize),
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct Vote {
167 pub intent_id: IntentId,
168 pub intent_digest: String,
169 pub policy_version: u64,
170 pub voter: AgentId,
171 pub choice: VoteChoice,
172 pub at_micros: u64,
173}
174
175impl Vote {
176 pub fn cast(intent: &Intent, voter: AgentId, choice: VoteChoice) -> Result<Self, IntentError> {
178 intent.validate()?;
179 if !intent.eligible_voters.contains(&voter) {
180 return Err(IntentError::IneligibleVoter(voter.to_string()));
181 }
182 Ok(Self {
183 intent_id: intent.intent_id,
184 intent_digest: intent.digest.clone(),
185 policy_version: intent.policy_version,
186 voter,
187 choice,
188 at_micros: now_micros(),
189 })
190 }
191}
192
193#[derive(
195 Debug,
196 Clone,
197 Copy,
198 PartialEq,
199 Eq,
200 Serialize,
201 Deserialize,
202 strum::Display,
203 strum::EnumString,
204 strum::IntoStaticStr,
205)]
206#[strum(serialize_all = "snake_case")]
207#[serde(rename_all = "snake_case")]
208pub enum VoteChoice {
209 Allow,
210 Block,
211 Abstain,
212}
213
214#[derive(
216 Debug,
217 Clone,
218 Copy,
219 PartialEq,
220 Eq,
221 Serialize,
222 Deserialize,
223 strum::Display,
224 strum::EnumString,
225 strum::IntoStaticStr,
226)]
227#[strum(serialize_all = "snake_case")]
228#[serde(rename_all = "snake_case")]
229pub enum IntentOutcome {
230 Committed,
231 Aborted,
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct Decision {
237 pub intent_id: IntentId,
238 pub intent_digest: String,
239 pub policy_version: u64,
240 pub outcome: IntentOutcome,
241 pub reason: String,
242 pub votes_considered: Vec<(AgentId, VoteChoice)>,
244 pub at_micros: u64,
246}
247
248impl Decision {
249 pub fn authorizes(&self, intent: &Intent) -> Result<bool, IntentError> {
252 intent.validate()?;
253 if self.intent_id != intent.intent_id
254 || self.intent_digest != intent.digest
255 || self.policy_version != intent.policy_version
256 {
257 return Err(IntentError::DecisionIntentMismatch);
258 }
259 Ok(self.outcome == IntentOutcome::Committed)
260 }
261}
262
263pub fn decide(
272 intent: &Intent,
273 votes: &[Vote],
274 now_micros: u64,
275) -> Result<Option<Decision>, IntentError> {
276 intent.validate()?;
277 let mut valid: Vec<&Vote> = votes
278 .iter()
279 .filter(|vote| {
280 vote.intent_id == intent.intent_id
281 && vote.intent_digest == intent.digest
282 && vote.policy_version == intent.policy_version
283 && intent.eligible_voters.contains(&vote.voter)
284 && vote.at_micros >= intent.at_micros
285 && vote.at_micros <= intent.deadline_micros
286 && vote.at_micros <= now_micros
287 })
288 .collect();
289 valid.sort_by(|left, right| {
290 left.voter
291 .as_str()
292 .cmp(right.voter.as_str())
293 .then(left.at_micros.cmp(&right.at_micros))
294 .then(choice_rank(left.choice).cmp(&choice_rank(right.choice)))
295 });
296
297 let mut ballots: HashMap<&AgentId, VoteChoice> = HashMap::new();
298 let mut considered = Vec::new();
299 let mut terminal_at = intent.at_micros;
300 for vote in valid {
301 terminal_at = terminal_at.max(vote.at_micros);
302 match ballots.get(&vote.voter) {
303 None => {
304 ballots.insert(&vote.voter, vote.choice);
305 considered.push((vote.voter.clone(), vote.choice));
306 }
307 Some(existing) if *existing == vote.choice => {}
308 Some(_) => {
309 considered.push((vote.voter.clone(), vote.choice));
310 return Ok(Some(sealed(
311 intent,
312 IntentOutcome::Aborted,
313 format!("voter '{}' cast conflicting votes", vote.voter),
314 considered,
315 terminal_at,
316 )));
317 }
318 }
319 }
320
321 for voter in &intent.mandatory_voters {
322 if let Some(choice) = ballots.get(voter)
323 && *choice != VoteChoice::Allow
324 {
325 return Ok(Some(sealed(
326 intent,
327 IntentOutcome::Aborted,
328 format!("mandatory voter '{voter}' did not allow"),
329 considered,
330 terminal_at,
331 )));
332 }
333 }
334
335 let allow = ballots
336 .values()
337 .filter(|choice| **choice == VoteChoice::Allow)
338 .count();
339 let responded = ballots.len();
340 let total = intent.eligible_voters.len();
341 let mandatory_met = intent
342 .mandatory_voters
343 .iter()
344 .all(|voter| ballots.get(voter) == Some(&VoteChoice::Allow));
345 let quorum_met = match intent.policy {
346 IntentPolicy::All => allow == total,
347 IntentPolicy::Any => allow >= 1,
348 IntentPolicy::AtLeast(required) => allow >= required,
349 };
350 if quorum_met && mandatory_met {
351 return Ok(Some(sealed(
352 intent,
353 IntentOutcome::Committed,
354 "quorum met".to_owned(),
355 considered,
356 terminal_at,
357 )));
358 }
359
360 let impossible = match intent.policy {
361 IntentPolicy::All => responded > allow,
362 IntentPolicy::Any => responded == total && allow == 0,
363 IntentPolicy::AtLeast(required) => allow + (total - responded) < required,
364 };
365 let deadline_passed = now_micros >= intent.deadline_micros;
366 if impossible || deadline_passed {
367 let reason = if deadline_passed && !mandatory_met {
368 "mandatory approval not reached by deadline".to_owned()
369 } else if impossible {
370 "quorum became impossible".to_owned()
371 } else {
372 "quorum not reached by deadline".to_owned()
373 };
374 return Ok(Some(sealed(
375 intent,
376 IntentOutcome::Aborted,
377 reason,
378 considered,
379 if deadline_passed {
380 intent.deadline_micros
381 } else {
382 terminal_at
383 },
384 )));
385 }
386 Ok(None)
387}
388
389fn choice_rank(choice: VoteChoice) -> u8 {
390 match choice {
391 VoteChoice::Allow => 0,
392 VoteChoice::Block => 1,
393 VoteChoice::Abstain => 2,
394 }
395}
396
397fn sealed(
398 intent: &Intent,
399 outcome: IntentOutcome,
400 reason: String,
401 votes_considered: Vec<(AgentId, VoteChoice)>,
402 at_micros: u64,
403) -> Decision {
404 Decision {
405 intent_id: intent.intent_id,
406 intent_digest: intent.digest.clone(),
407 policy_version: intent.policy_version,
408 outcome,
409 reason,
410 votes_considered,
411 at_micros,
412 }
413}
414
415fn digest_of(body: &[u8]) -> String {
416 #[derive(Serialize)]
417 struct DigestInput<'a> {
418 body: &'a [u8],
419 }
420 let canonical = encode_named(&DigestInput { body }).expect("bytes always encode");
421 blake3::hash(&canonical).to_string()
422}
423
424fn now_micros() -> u64 {
425 std::time::SystemTime::now()
426 .duration_since(std::time::UNIX_EPOCH)
427 .map(|elapsed| u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX))
428 .unwrap_or(0)
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn given_an_intent_error_when_propagated_with_question_mark_then_should_be_invalid() {
437 fn propose() -> Result<(), crate::error::LaserError> {
438 Err(IntentError::NoEligibleVoters)?;
439 Ok(())
440 }
441 assert!(matches!(
442 propose(),
443 Err(crate::error::LaserError::Invalid(message)) if message.contains("eligible voter")
444 ));
445 }
446
447 fn agent(name: &str) -> AgentId {
448 name.parse().expect("agent id parses")
449 }
450
451 fn intent(policy: IntentPolicy, voters: &[&str], mandatory: &[&str]) -> Intent {
452 let mut intent = Intent::builder()
453 .conversation(ConversationId::new())
454 .proposer(agent("proposer"))
455 .body(b"transfer $100".to_vec())
456 .eligible_voters(voters.iter().map(|voter| agent(voter)).collect())
457 .mandatory_voters(mandatory.iter().map(|voter| agent(voter)).collect())
458 .policy(policy)
459 .policy_version(1)
460 .deadline_micros(u64::MAX)
461 .build()
462 .expect("intent is valid");
463 intent.at_micros = 10;
464 intent.deadline_micros = 1_000;
465 intent
466 }
467
468 fn vote(intent: &Intent, voter: &str, choice: VoteChoice) -> Vote {
469 let mut vote = Vote::cast(intent, agent(voter), choice).expect("voter is eligible");
470 vote.at_micros = 100;
471 vote
472 }
473
474 fn decided(intent: &Intent, votes: &[Vote], now: u64) -> Decision {
475 decide(intent, votes, now)
476 .expect("intent is valid")
477 .expect("intent decides")
478 }
479
480 #[test]
481 fn given_invalid_voter_sets_when_built_then_should_fail_closed() {
482 let result = Intent::builder()
483 .conversation(ConversationId::new())
484 .proposer(agent("proposer"))
485 .body(Vec::new())
486 .eligible_voters(Vec::new())
487 .policy(IntentPolicy::Any)
488 .policy_version(1)
489 .deadline_micros(u64::MAX)
490 .build();
491 assert_eq!(result, Err(IntentError::NoEligibleVoters));
492
493 let mut duplicate = intent(IntentPolicy::Any, &["a"], &[]);
494 duplicate.eligible_voters.push(agent("a"));
495 assert_eq!(
496 duplicate.validate(),
497 Err(IntentError::DuplicateEligibleVoter("a".to_owned()))
498 );
499 }
500
501 #[test]
502 fn given_invalid_thresholds_when_built_then_should_fail_closed() {
503 for required in [0, 2] {
504 let result = Intent::builder()
505 .conversation(ConversationId::new())
506 .proposer(agent("proposer"))
507 .body(Vec::new())
508 .eligible_voters(vec![agent("a")])
509 .policy(IntentPolicy::AtLeast(required))
510 .policy_version(1)
511 .deadline_micros(u64::MAX)
512 .build();
513 assert!(matches!(result, Err(IntentError::InvalidThreshold { .. })));
514 }
515 }
516
517 #[test]
518 fn given_a_mutated_body_when_decided_then_should_reject_the_intent() {
519 let mut intent = intent(IntentPolicy::Any, &["a"], &[]);
520 intent.body.push(1);
521 assert_eq!(decide(&intent, &[], 500), Err(IntentError::DigestMismatch));
522 }
523
524 #[test]
525 fn given_an_outsider_when_casting_then_should_reject_the_vote() {
526 let intent = intent(IntentPolicy::Any, &["a"], &[]);
527 assert_eq!(
528 Vote::cast(&intent, agent("outsider"), VoteChoice::Allow),
529 Err(IntentError::IneligibleVoter("outsider".to_owned()))
530 );
531 }
532
533 #[test]
534 fn given_all_policy_with_every_allow_when_decided_then_should_commit() {
535 let intent = intent(IntentPolicy::All, &["a", "b"], &[]);
536 let votes = [
537 vote(&intent, "a", VoteChoice::Allow),
538 vote(&intent, "b", VoteChoice::Allow),
539 ];
540 let decision = decided(&intent, &votes, 500);
541 assert_eq!(decision.outcome, IntentOutcome::Committed);
542 assert_eq!(decision.intent_digest, intent.digest);
543 assert_eq!(decision.policy_version, 1);
544 assert!(decision.authorizes(&intent).expect("decision is bound"));
545 }
546
547 #[test]
548 fn given_any_policy_with_every_non_allow_when_decided_then_should_abort_early() {
549 let intent = intent(IntentPolicy::Any, &["a", "b"], &[]);
550 let votes = [
551 vote(&intent, "a", VoteChoice::Block),
552 vote(&intent, "b", VoteChoice::Abstain),
553 ];
554 assert_eq!(
555 decided(&intent, &votes, 500).outcome,
556 IntentOutcome::Aborted
557 );
558 }
559
560 #[test]
561 fn given_reachable_threshold_when_decided_then_should_wait() {
562 let intent = intent(IntentPolicy::AtLeast(2), &["a", "b", "c"], &[]);
563 let votes = [vote(&intent, "a", VoteChoice::Allow)];
564 assert_eq!(decide(&intent, &votes, 500).expect("valid intent"), None);
565 }
566
567 #[test]
568 fn given_impossible_threshold_when_decided_then_should_abort_early() {
569 let intent = intent(IntentPolicy::AtLeast(2), &["a", "b", "c"], &[]);
570 let votes = [
571 vote(&intent, "a", VoteChoice::Block),
572 vote(&intent, "b", VoteChoice::Block),
573 ];
574 assert_eq!(
575 decided(&intent, &votes, 500).outcome,
576 IntentOutcome::Aborted
577 );
578 }
579
580 #[test]
581 fn given_missing_mandatory_allow_when_quorum_met_then_should_wait_then_abort() {
582 let intent = intent(IntentPolicy::Any, &["safety", "llm"], &["safety"]);
583 let votes = [vote(&intent, "llm", VoteChoice::Allow)];
584 assert_eq!(decide(&intent, &votes, 500).expect("valid intent"), None);
585 let decision = decided(&intent, &votes, 1_000);
586 assert_eq!(decision.outcome, IntentOutcome::Aborted);
587 assert!(decision.reason.contains("mandatory"));
588 }
589
590 #[test]
591 fn given_mandatory_allow_and_quorum_when_decided_then_should_commit() {
592 let intent = intent(IntentPolicy::Any, &["safety", "llm"], &["safety"]);
593 let votes = [vote(&intent, "safety", VoteChoice::Allow)];
594 assert_eq!(
595 decided(&intent, &votes, 500).outcome,
596 IntentOutcome::Committed
597 );
598 }
599
600 #[test]
601 fn given_late_allow_when_decided_then_should_ignore_it_and_abort() {
602 let intent = intent(IntentPolicy::Any, &["a"], &[]);
603 let mut late = vote(&intent, "a", VoteChoice::Allow);
604 late.at_micros = 1_001;
605 assert_eq!(
606 decided(&intent, &[late], 1_001).outcome,
607 IntentOutcome::Aborted
608 );
609 }
610
611 #[test]
612 fn given_a_future_ballot_when_decided_then_should_ignore_it() {
613 let intent = intent(IntentPolicy::Any, &["a"], &[]);
614 let mut future = vote(&intent, "a", VoteChoice::Allow);
615 future.at_micros = 600;
616 assert_eq!(decide(&intent, &[future], 500).expect("valid intent"), None);
617 }
618
619 #[test]
620 fn given_conflicting_votes_in_any_order_when_decided_then_should_match() {
621 let intent = intent(IntentPolicy::All, &["a", "b"], &[]);
622 let allow = vote(&intent, "a", VoteChoice::Allow);
623 let mut block = vote(&intent, "a", VoteChoice::Block);
624 block.at_micros = 200;
625 let first = decided(&intent, &[allow.clone(), block.clone()], 500);
626 let second = decided(&intent, &[block, allow], 500);
627 assert_eq!(first, second);
628 assert!(first.reason.contains("conflicting"));
629 }
630
631 #[test]
632 fn given_identical_repeats_when_decided_then_should_be_idempotent() {
633 let intent = intent(IntentPolicy::All, &["a", "b"], &[]);
634 let allow = vote(&intent, "a", VoteChoice::Allow);
635 assert_eq!(
636 decide(&intent, &[allow.clone(), allow], 500).expect("valid intent"),
637 None
638 );
639 }
640
641 #[test]
642 fn given_invalid_ballot_metadata_when_decided_then_should_ignore_it() {
643 let intent = intent(IntentPolicy::Any, &["a"], &[]);
644 let mut vote = vote(&intent, "a", VoteChoice::Allow);
645 vote.intent_digest = "wrong".to_owned();
646 assert_eq!(decide(&intent, &[vote], 500).expect("valid intent"), None);
647 }
648
649 #[test]
650 fn given_an_intent_when_round_tripped_then_should_validate() {
651 let intent = intent(IntentPolicy::Any, &["a"], &[]);
652 let json = serde_json::to_vec(&intent).expect("encodes");
653 let decoded: Intent = serde_json::from_slice(&json).expect("decodes");
654 assert_eq!(decoded, intent);
655 decoded.validate().expect("round trip remains valid");
656 }
657
658 #[test]
659 fn given_durable_records_when_encoded_as_json_then_enum_words_should_be_snake_case() {
660 assert_eq!(
661 serde_json::to_value(IntentPolicy::AtLeast(1)).expect("policy encodes"),
662 serde_json::json!({ "at_least": 1 })
663 );
664 assert_eq!(
665 serde_json::to_value(VoteChoice::Abstain).expect("choice encodes"),
666 serde_json::json!("abstain")
667 );
668 assert_eq!(
669 serde_json::to_value(IntentOutcome::Aborted).expect("outcome encodes"),
670 serde_json::json!("aborted")
671 );
672 }
673}