Skip to main content

laser_sdk/
intent.rs

1use crate::types::{AgentId, ConversationId, IntentId};
2use laser_wire::framing::encode_named;
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5
6/// A durable proposal for an effect. Publish it like any other typed record,
7/// fold the [`Vote`] records that name it, and call [`decide`] for a replayable
8/// outcome. This module defines record shapes and a pure fold, not a new wire
9/// protocol or an implicit log integration.
10///
11/// `proposer` and `voter` are record claims. A deployment that needs trusted
12/// authorship must enforce a signed-principal or topology-isolated profile.
13/// This pure fold does not authenticate who appended a record.
14///
15/// This composes with, and does not replace, [`crate::govern::ActionGovernor`].
16/// A governor can require a committed decision before releasing an effect.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Intent {
19    /// A fresh ULID stamped at build time.
20    pub intent_id: IntentId,
21    /// The conversation and usual partition key for this intent.
22    pub conversation: ConversationId,
23    /// The proposing agent.
24    pub proposer: AgentId,
25    /// The proposed effect, opaque to this module.
26    pub body: Vec<u8>,
27    /// BLAKE3 over the canonical named-field CBOR encoding of `body`.
28    pub digest: String,
29    /// The only principals whose votes count.
30    pub eligible_voters: Vec<AgentId>,
31    /// Eligible principals that must vote `Allow` before commit.
32    pub mandatory_voters: Vec<AgentId>,
33    /// How affirmative votes combine.
34    pub policy: IntentPolicy,
35    /// The policy version frozen when this intent was proposed.
36    pub policy_version: u64,
37    /// Epoch micros after which an incomplete intent aborts.
38    pub deadline_micros: u64,
39    /// Proposal time, in epoch micros.
40    pub at_micros: u64,
41}
42
43#[bon::bon]
44impl Intent {
45    /// Build and validate an intent before it can be published.
46    #[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    /// Validate a constructed or deserialized intent.
75    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/// An invalid durable-intent configuration or operation.
119#[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
141// An intent rejected at construction, voting, or folding is a validation
142// failure the caller fixes, never a retry, so it is `LaserError::Invalid` like
143// an envelope the validity matrix rejects. Python and TypeScript raise their
144// `IntentError` as an `InvalidError` subclass the same way.
145impl From<IntentError> for crate::error::LaserError {
146    fn from(error: IntentError) -> Self {
147        crate::error::LaserError::Invalid(error.to_string())
148    }
149}
150
151/// How [`Vote`]s combine into a [`Decision`].
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum IntentPolicy {
155    /// Every eligible voter must allow.
156    All,
157    /// At least one eligible voter must allow.
158    Any,
159    /// At least `n` distinct eligible voters must allow.
160    AtLeast(usize),
161}
162
163/// One principal's claimed ballot on one intent. Trusted authorship comes from
164/// the deployment's signing or topic-write policy, not this record shape.
165#[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    /// Cast a ballot bound to the intent's digest and policy version.
177    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/// A durable ballot choice.
194#[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/// The terminal outcome of one intent.
215#[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/// A decision bound to one exact intent body and policy version.
235#[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    /// Canonically ordered `(voter, choice)` pairs used by the fold.
243    pub votes_considered: Vec<(AgentId, VoteChoice)>,
244    /// The fact time that made the decision terminal, not replay wall time.
245    pub at_micros: u64,
246}
247
248impl Decision {
249    /// Verify that this decision is bound to `intent` before releasing an
250    /// effect. An aborted decision is valid evidence but does not authorize.
251    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
263/// Fold ballots into a decision, or return `None` while the outcome remains
264/// reachable before the deadline.
265///
266/// Invalid ballots are ignored. Ballots outside the proposal-to-deadline
267/// window are invalid. Identical repeats are idempotent. Conflicting choices
268/// by one voter abort. Mandatory voters must allow before a quorum can commit.
269/// The valid ballot set is sorted before folding, so input and replay order do
270/// not affect the decision or its evidence.
271pub 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}