Skip to main content

laser_sdk/
govern.rs

1use crate::error::LaserError;
2use crate::laser::Laser;
3use crate::provenance::AgentTopic;
4use crate::types::{ConversationId, MintUlid};
5use async_trait::async_trait;
6use laser_wire::agent::{AgentEnvelope, AgentId, RecordId, validate};
7use laser_wire::content::ContentType;
8use laser_wire::framing::{decode_named, encode_named};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14/// The `operation` stamped on every policy-evidence event, so the audit topic
15/// filters governance decisions without decoding bodies.
16pub const POLICY_DECISION_OPERATION: &str = "policy_decision";
17
18/// The pre-effect policy hook: [`decide`](Self::decide) runs before the SDK
19/// publishes an agent send, a typed or raw topic publish, an AGDX verb, or a
20/// memory write, and its
21/// [`ActionDecision`] is applied under the configured [`GovernorMode`]. Enroll
22/// one with [`Laser::with_governor`], `LaserBuilder::governor`, or
23/// `Agent::builder().governor(..)`. Defense in depth at the effect boundary:
24/// RBAC on the managed surfaces stays server-owned, this hook cannot widen it.
25///
26/// An `Err` fails the governed action (fail closed), so a broken governor
27/// never fails open.
28#[async_trait]
29pub trait ActionGovernor: Send + Sync {
30    async fn decide(&self, action: &GovernedAction<'_>) -> Result<ActionDecision, LaserError>;
31}
32
33/// One side effect about to run, as the [`ActionGovernor`] sees it. Every
34/// agent-written field here is a claim (the AGDX trusted-versus-advisory rule):
35/// `purpose` and `data_classification` are advisory unless the envelope is
36/// signed, and `signed` says only that this SDK will sign the record, not that
37/// a peer verified it.
38#[derive(Debug)]
39pub struct GovernedAction<'a> {
40    /// What kind of effect this is.
41    pub kind: ActionKind,
42    /// The Iggy stream the effect publishes to.
43    pub stream: &'a str,
44    /// The topic the effect publishes to.
45    pub topic: &'a str,
46    /// The acting agent, when the effect carries one.
47    pub source: Option<&'a str>,
48    /// The addressed agent, when the effect targets one.
49    pub target: Option<&'a str>,
50    /// The conversation the effect belongs to.
51    pub conversation: Option<ConversationId>,
52    /// The reply-correlation key, when the effect carries one.
53    pub correlation: Option<&'a str>,
54    /// The envelope operation name (AGDX path).
55    pub operation: Option<&'a str>,
56    /// The tool name (AGDX path).
57    pub tool: Option<&'a str>,
58    /// The delegation subject from the envelope metadata (`on_behalf_of`).
59    pub on_behalf_of: Option<&'a str>,
60    /// The declared purpose from the envelope metadata (advisory).
61    pub purpose: Option<&'a str>,
62    /// The declared data classification from the envelope metadata (advisory).
63    pub data_classification: Option<&'a str>,
64    /// The body about to be published.
65    pub payload: &'a [u8],
66    /// Whether this SDK will sign the record at send.
67    pub signed: bool,
68    /// Session counters at decision time, for rate and budget policies.
69    pub counters: ActionCounters,
70}
71
72/// The kind of side effect a [`GovernedAction`] describes. Displays as its
73/// snake_case evidence name (`send` | `publish` | `request` | `command` |
74/// `response` | `event` | `status` | `error` | `memory_write`) and parses back
75/// from it.
76#[derive(
77    Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::EnumString, strum::IntoStaticStr,
78)]
79#[strum(serialize_all = "snake_case")]
80#[non_exhaustive]
81pub enum ActionKind {
82    /// A plain agent-topic send (`send_agent`, `ctx.send`, `respond`).
83    Send,
84    /// A typed or raw publish to a data topic (`topic(...).publish()`).
85    Publish,
86    /// A request awaiting a correlated reply (`request`, `fan_out` branches).
87    Request,
88    /// An AGDX `command`.
89    Command,
90    /// An AGDX `response`.
91    Response,
92    /// An AGDX `event`.
93    Event,
94    /// An AGDX `status`.
95    Status,
96    /// An AGDX `error` terminal.
97    Error,
98    /// A memory write (`remember`, `improve`, `forget`).
99    MemoryWrite,
100}
101
102impl ActionKind {
103    /// The pinned evidence name of this kind.
104    pub fn as_str(self) -> &'static str {
105        self.into()
106    }
107}
108
109/// Session counters at decision time. Shared by every clone of the governed
110/// [`Laser`], so a policy can bound a whole session, not one handle.
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
112pub struct ActionCounters {
113    /// Governed non-request effects so far.
114    pub sends: u64,
115    /// Governed requests so far.
116    pub requests: u64,
117    /// Payload bytes published through governed effects so far.
118    pub bytes_sent: u64,
119}
120
121/// What the [`ActionGovernor`] decided, with the optional evidence detail
122/// (reason, policy provenance, risk score) recorded in the [`PolicyEvidence`]
123/// event. Build with the constructors and refine with the `with_*` methods.
124#[derive(Debug, Clone, PartialEq)]
125pub struct ActionDecision {
126    /// The verdict to apply.
127    pub verdict: Verdict,
128    /// Why, recorded in evidence.
129    pub reason: Option<String>,
130    /// The policy pack and rules that decided, recorded in evidence.
131    pub policy: Option<PolicyRef>,
132    /// The governor's risk estimate, recorded in evidence.
133    pub risk_score: Option<f64>,
134}
135
136impl ActionDecision {
137    /// Run the effect, no evidence.
138    pub fn allow() -> Self {
139        Self::of(Verdict::Allow)
140    }
141
142    /// Run the effect and record evidence.
143    pub fn observe() -> Self {
144        Self::of(Verdict::Observe)
145    }
146
147    /// Reject before the effect ([`LaserError::PolicyBlocked`]).
148    pub fn block(reason: impl Into<String>) -> Self {
149        let mut decision = Self::of(Verdict::Block);
150        decision.reason = Some(reason.into());
151        decision
152    }
153
154    /// Reject with the scope an approval must grant
155    /// ([`LaserError::StepUpRequired`]). The handler catches it, runs an
156    /// [`approval_gate`](crate::agent::AgentCtx::approval_gate), and re-sends.
157    pub fn step_up(scope: impl Into<String>) -> Self {
158        Self::of(Verdict::StepUp {
159            scope: scope.into(),
160        })
161    }
162
163    /// Replace the body before the effect. Applied before claim-check and
164    /// signing, so a signature always covers the body the log carries.
165    pub fn modify(body: impl Into<Vec<u8>>) -> Self {
166        Self::of(Verdict::Modify { body: body.into() })
167    }
168
169    /// Hold the work for later ([`LaserError::PolicyDeferred`], retryable).
170    pub fn defer(reason: impl Into<String>) -> Self {
171        let mut decision = Self::of(Verdict::Defer);
172        decision.reason = Some(reason.into());
173        decision
174    }
175
176    /// Record why, in evidence.
177    #[must_use]
178    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
179        self.reason = Some(reason.into());
180        self
181    }
182
183    /// Record the deciding policy pack and rules, in evidence.
184    #[must_use]
185    pub fn with_policy(mut self, policy: PolicyRef) -> Self {
186        self.policy = Some(policy);
187        self
188    }
189
190    /// Record the governor's risk estimate, in evidence.
191    #[must_use]
192    pub fn with_risk_score(mut self, risk_score: f64) -> Self {
193        self.risk_score = Some(risk_score);
194        self
195    }
196
197    fn of(verdict: Verdict) -> Self {
198        Self {
199            verdict,
200            reason: None,
201            policy: None,
202            risk_score: None,
203        }
204    }
205}
206
207/// The decision vocabulary, broader than allow and deny. Displays as its
208/// snake_case evidence name (`allow` | `observe` | `block` | `step_up` |
209/// `modify` | `defer`).
210#[derive(Debug, Clone, PartialEq, strum::Display, strum::IntoStaticStr)]
211#[strum(serialize_all = "snake_case")]
212pub enum Verdict {
213    /// Run the effect.
214    Allow,
215    /// Run the effect and emit evidence.
216    Observe,
217    /// Reject before the effect.
218    Block,
219    /// Pause on an approval granting `scope`.
220    StepUp { scope: String },
221    /// Replace the body, then run the effect.
222    Modify { body: Vec<u8> },
223    /// Record that the work is held for later.
224    Defer,
225}
226
227impl Verdict {
228    /// The pinned evidence name of this verdict.
229    pub fn as_str(&self) -> &'static str {
230        self.into()
231    }
232}
233
234/// The versioned policy artifact a decision came from, recorded verbatim in
235/// evidence. The SDK parses no policy language: a governor maps whatever
236/// engine it fronts onto this.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct PolicyRef {
239    /// The policy pack id.
240    pub pack_id: String,
241    /// The policy pack version.
242    pub pack_version: String,
243    /// The rule ids that matched.
244    pub rule_ids: Vec<String>,
245}
246
247/// How a decision is applied. Configuration, never an envelope claim: an agent
248/// must not self-authorize weaker enforcement. Displays as its snake_case
249/// evidence name (`observe` | `enforce`) and parses back from it.
250#[derive(
251    Debug,
252    Clone,
253    Copy,
254    Default,
255    PartialEq,
256    Eq,
257    strum::Display,
258    strum::EnumString,
259    strum::IntoStaticStr,
260)]
261#[strum(serialize_all = "snake_case")]
262pub enum GovernorMode {
263    /// Shadow rollout: every decision is recorded with what enforcement would
264    /// have done, the effect always runs unmodified, and an evidence-write
265    /// failure only warns. Observing never impacts production.
266    Observe,
267    /// Apply the verdict: block, step up, modify, or defer before the effect.
268    /// An evidence-write failure on a proceeding decision fails the call, so a
269    /// governed effect is never unrecorded.
270    #[default]
271    Enforce,
272}
273
274impl GovernorMode {
275    /// The pinned evidence name of this mode.
276    pub fn as_str(self) -> &'static str {
277        self.into()
278    }
279}
280
281/// One governance decision, appended as an AGDX `event` (operation
282/// [`POLICY_DECISION_OPERATION`], content-type cbor) on the audit topic.
283/// Append-only evidence, never a grant. `receipt_digest` is the BLAKE3 hash of
284/// this record's canonical encoding with the digest field empty, and
285/// `previous_digest` chains it to the prior decision in the same conversation,
286/// so reordering or dropping local evidence is detectable. Signed evidence
287/// (a governed `Laser` whose sends are signed) additionally proves the
288/// producer, and the log position proves order on the substrate.
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct PolicyEvidence {
291    /// This decision's id (ULID).
292    pub decision_id: String,
293    /// The verdict name (`allow` | `observe` | `block` | `step_up` | `modify` | `defer`).
294    pub decision: String,
295    /// The enforcement mode the decision ran under.
296    pub mode: String,
297    /// The governed action's kind.
298    pub kind: String,
299    /// The stream the action targeted.
300    pub stream: String,
301    /// The topic the action targeted.
302    pub topic: String,
303    #[serde(skip_serializing_if = "Option::is_none", default)]
304    pub source: Option<String>,
305    #[serde(skip_serializing_if = "Option::is_none", default)]
306    pub target: Option<String>,
307    #[serde(skip_serializing_if = "Option::is_none", default)]
308    pub conversation: Option<String>,
309    #[serde(skip_serializing_if = "Option::is_none", default)]
310    pub correlation: Option<String>,
311    #[serde(skip_serializing_if = "Option::is_none", default)]
312    pub operation: Option<String>,
313    #[serde(skip_serializing_if = "Option::is_none", default)]
314    pub tool: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none", default)]
316    pub on_behalf_of: Option<String>,
317    /// The governor's reason, when it gave one.
318    #[serde(skip_serializing_if = "Option::is_none", default)]
319    pub reason: Option<String>,
320    /// The scope a step-up approval must grant.
321    #[serde(skip_serializing_if = "Option::is_none", default)]
322    pub approved_scope: Option<String>,
323    /// The deciding policy pack and rules.
324    #[serde(skip_serializing_if = "Option::is_none", default)]
325    pub policy: Option<PolicyRef>,
326    /// The governor's risk estimate.
327    #[serde(skip_serializing_if = "Option::is_none", default)]
328    pub risk_score: Option<f64>,
329    /// BLAKE3 (hex) of this record's canonical encoding, digest field empty.
330    pub receipt_digest: String,
331    /// The prior decision's `receipt_digest` in this conversation.
332    #[serde(skip_serializing_if = "Option::is_none", default)]
333    pub previous_digest: Option<String>,
334    /// What the decision did to the effect: `effected` (the decision let it
335    /// proceed, the publish itself can still fail downstream) | `blocked` |
336    /// `step_up` | `deferred`.
337    pub outcome: String,
338    /// Decision time, epoch micros.
339    pub at_micros: u64,
340}
341
342impl PolicyEvidence {
343    /// The canonical named-field CBOR encoding this evidence rides the log as.
344    pub fn encode(&self) -> Result<Vec<u8>, LaserError> {
345        Ok(encode_named(self)?)
346    }
347
348    /// Decode an evidence body read back off the audit topic.
349    pub fn decode(payload: &[u8]) -> Result<Self, LaserError> {
350        Ok(decode_named(payload)?)
351    }
352
353    /// Seal this record: compute [`receipt_digest`](Self::receipt_digest) over
354    /// the canonical encoding with the digest field empty.
355    fn seal(mut self) -> Result<Self, LaserError> {
356        self.receipt_digest = String::new();
357        let canonical = self.encode()?;
358        self.receipt_digest = blake3::hash(&canonical).to_string();
359        Ok(self)
360    }
361}
362
363/// Whether `evidence`, in log order for one conversation, is an unbroken
364/// chain: every record reproduces its own `receipt_digest` and names the
365/// previous record's digest as its `previous_digest`. A reordered, dropped,
366/// or edited record breaks the chain.
367pub fn verify_evidence_chain(evidence: &[PolicyEvidence]) -> bool {
368    let mut previous: Option<&str> = None;
369    for item in evidence {
370        if item.previous_digest.as_deref() != previous {
371            return false;
372        }
373        match item.clone().seal() {
374            Ok(sealed) if sealed.receipt_digest == item.receipt_digest => {}
375            _ => return false,
376        }
377        previous = Some(item.receipt_digest.as_str());
378    }
379    true
380}
381
382impl Laser {
383    /// A clone of this `Laser` whose agent sends, typed or raw topic
384    /// publishes, AGDX verbs, and memory writes run `governor` before the
385    /// effect, applied under `mode`. The connection, producer cache, and
386    /// everything else are shared with the original, while the governor's
387    /// session counters and evidence chain are fresh and shared by every clone
388    /// of the returned handle. Agents spawned from the governed handle inherit
389    /// it, so a handler's `ctx` effects are governed too.
390    #[must_use]
391    pub fn with_governor(&self, governor: Arc<dyn ActionGovernor>, mode: GovernorMode) -> Self {
392        self.with_governor_retention(governor, mode, GovernorRetention::default())
393    }
394
395    /// The retained evidence-chain policy variant of [`Self::with_governor`].
396    /// Chain heads are process-local. A restart or retention eviction starts a
397    /// new local chain for that conversation.
398    #[must_use]
399    pub fn with_governor_retention(
400        &self,
401        governor: Arc<dyn ActionGovernor>,
402        mode: GovernorMode,
403        retention: GovernorRetention,
404    ) -> Self {
405        let mut governed = self.clone();
406        governed.governor = Some(Arc::new(GovernorState::with_retention(
407            governor, mode, retention,
408        )));
409        governed
410    }
411
412    // Run the governor over `action` when one is enrolled: decide, record
413    // evidence, and apply the verdict under the mode. `Ok(None)` proceeds with
414    // the original body, `Ok(Some(body))` proceeds with the replacement, `Err`
415    // is the applied denial (or the fail-closed governor/evidence failure).
416    pub(crate) async fn govern(
417        &self,
418        mut action: GovernedAction<'_>,
419    ) -> Result<Option<Vec<u8>>, LaserError> {
420        let Some(state) = self.governor.clone() else {
421            return Ok(None);
422        };
423        action.counters = state.snapshot();
424        let decision = state.governor.decide(&action).await?;
425        match action.kind {
426            ActionKind::Request => state.requests.fetch_add(1, Ordering::Relaxed),
427            _ => state.sends.fetch_add(1, Ordering::Relaxed),
428        };
429        let applied = apply(state.mode, decision.verdict.clone());
430        if applied.recorded {
431            let emitted = state
432                .emit_chained_evidence(self, &action, &decision, applied.outcome)
433                .await;
434            match state.mode {
435                // Shadow never impacts production: a failed evidence write warns.
436                GovernorMode::Observe => {
437                    if let Err(error) = emitted {
438                        tracing::warn!(%error, "policy evidence write failed (observe mode)");
439                    }
440                }
441                GovernorMode::Enforce => match (&applied.denial, emitted) {
442                    // A proceeding decision with no recorded evidence must not
443                    // proceed: fail closed rather than run an unrecorded effect.
444                    (None, Err(error)) => return Err(error),
445                    // The denial stands whether or not its evidence landed.
446                    (Some(_), Err(error)) => {
447                        tracing::warn!(%error, "policy evidence write failed (denial stands)");
448                    }
449                    (_, Ok(())) => {}
450                },
451            }
452        }
453        if let Some(denial) = applied.denial {
454            return Err(denial);
455        }
456        // Count what actually goes out: the replacement body when modified.
457        let sent = applied.body.as_ref().map_or(action.payload.len(), Vec::len);
458        state.bytes_sent.fetch_add(sent as u64, Ordering::Relaxed);
459        Ok(applied.body)
460    }
461
462    // Publish `evidence` as an AGDX `event` on the audit topic of the action's
463    // stream, keyed by the conversation. Goes straight to the batch path, never
464    // back through the governed verbs, so evidence cannot recurse into
465    // governance.
466    async fn emit_evidence(
467        &self,
468        action: &GovernedAction<'_>,
469        evidence: PolicyEvidence,
470    ) -> Result<(), LaserError> {
471        let source: AgentId = action
472            .source
473            .and_then(|source| source.parse().ok())
474            .unwrap_or_else(|| "governor".parse().expect("static agent id is valid"));
475        let conversation = action.conversation.unwrap_or_default();
476        let envelope = AgentEnvelope::event(
477            RecordId::mint(),
478            conversation.into(),
479            source,
480            evidence.encode()?,
481        )
482        .with_operation(POLICY_DECISION_OPERATION);
483        validate(&envelope)?;
484        let payload = encode_named(&envelope)?;
485        let headers = crate::agent::agdx_headers(&envelope, ContentType::Cbor, false)?;
486        let message = iggy::prelude::IggyMessage::builder()
487            .payload(bytes::Bytes::from(payload))
488            .user_headers(headers)
489            .build()?;
490        let partition_key = conversation.to_string();
491        self.send_batch_on(
492            action.stream,
493            &AgentTopic::Audit.topic_string(),
494            vec![message],
495            Some(&partition_key),
496        )
497        .await
498        .map(|_| ())
499    }
500}
501
502// The shared state of one governed `Laser` handle: the hook, the mode, the
503// session counters, and the per-conversation evidence digest chain.
504pub(crate) struct GovernorState {
505    governor: Arc<dyn ActionGovernor>,
506    mode: GovernorMode,
507    sends: AtomicU64,
508    requests: AtomicU64,
509    bytes_sent: AtomicU64,
510    chain: tokio::sync::Mutex<HashMap<Option<u128>, Arc<ChainEntry>>>,
511    chain_released: Arc<tokio::sync::Notify>,
512    retention: GovernorRetention,
513}
514
515struct ChainEntry {
516    digest: tokio::sync::Mutex<Option<String>>,
517    touched_micros: AtomicU64,
518}
519
520struct ChainLease {
521    entry: Arc<ChainEntry>,
522    released: Arc<tokio::sync::Notify>,
523}
524
525impl Drop for ChainLease {
526    fn drop(&mut self) {
527        self.entry
528            .touched_micros
529            .store(now_micros(), Ordering::Release);
530        self.released.notify_one();
531    }
532}
533
534/// Process-local retention for governance digest-chain heads. Eviction or process restart starts a new local chain for that conversation.
535#[derive(Clone, Copy, Debug)]
536pub struct GovernorRetention {
537    /// Maximum retained conversation heads.
538    pub capacity: usize,
539    /// Inactivity duration after which an unlocked head may be evicted.
540    pub idle_ttl: std::time::Duration,
541}
542
543impl Default for GovernorRetention {
544    fn default() -> Self {
545        Self {
546            capacity: 4_096,
547            idle_ttl: std::time::Duration::from_secs(3_600),
548        }
549    }
550}
551
552impl GovernorState {
553    pub(crate) fn new(governor: Arc<dyn ActionGovernor>, mode: GovernorMode) -> Self {
554        Self::with_retention(governor, mode, GovernorRetention::default())
555    }
556
557    pub(crate) fn with_retention(
558        governor: Arc<dyn ActionGovernor>,
559        mode: GovernorMode,
560        mut retention: GovernorRetention,
561    ) -> Self {
562        retention.capacity = retention.capacity.max(1);
563        Self {
564            governor,
565            mode,
566            sends: AtomicU64::new(0),
567            requests: AtomicU64::new(0),
568            bytes_sent: AtomicU64::new(0),
569            chain: tokio::sync::Mutex::new(HashMap::new()),
570            chain_released: Arc::new(tokio::sync::Notify::new()),
571            retention,
572        }
573    }
574
575    fn prune_chain(&self, chain: &mut HashMap<Option<u128>, Arc<ChainEntry>>, now_micros: u64) {
576        let ttl_micros = u64::try_from(self.retention.idle_ttl.as_micros()).unwrap_or(u64::MAX);
577        chain.retain(|_, entry| {
578            Arc::strong_count(entry) > 1
579                || now_micros.saturating_sub(entry.touched_micros.load(Ordering::Acquire))
580                    < ttl_micros
581        });
582    }
583
584    async fn acquire_chain(&self, chain_key: Option<u128>) -> ChainLease {
585        loop {
586            let released = self.chain_released.notified();
587            {
588                let now = now_micros();
589                let mut chain = self.chain.lock().await;
590                self.prune_chain(&mut chain, now);
591                if let Some(entry) = chain.get(&chain_key) {
592                    return ChainLease {
593                        entry: Arc::clone(entry),
594                        released: Arc::clone(&self.chain_released),
595                    };
596                }
597                while chain.len() >= self.retention.capacity {
598                    let oldest = chain
599                        .iter()
600                        .filter(|(_, entry)| Arc::strong_count(entry) == 1)
601                        .min_by_key(|(_, entry)| entry.touched_micros.load(Ordering::Acquire))
602                        .map(|(key, _)| *key);
603                    let Some(oldest) = oldest else {
604                        break;
605                    };
606                    chain.remove(&oldest);
607                }
608                if chain.len() < self.retention.capacity {
609                    let entry = Arc::new(ChainEntry {
610                        digest: tokio::sync::Mutex::new(None),
611                        touched_micros: AtomicU64::new(now),
612                    });
613                    chain.insert(chain_key, Arc::clone(&entry));
614                    return ChainLease {
615                        entry,
616                        released: Arc::clone(&self.chain_released),
617                    };
618                }
619            }
620            released.await;
621        }
622    }
623
624    fn snapshot(&self) -> ActionCounters {
625        ActionCounters {
626            sends: self.sends.load(Ordering::Relaxed),
627            requests: self.requests.load(Ordering::Relaxed),
628            bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
629        }
630    }
631
632    // Serialize audit publication per conversation. Advance the chain only
633    // after the record lands, so a failed write cannot become a predecessor.
634    async fn emit_chained_evidence(
635        &self,
636        laser: &Laser,
637        action: &GovernedAction<'_>,
638        decision: &ActionDecision,
639        outcome: &'static str,
640    ) -> Result<(), LaserError> {
641        let chain_key = action.conversation.map(|id| id.as_u128());
642        let slot = self.acquire_chain(chain_key).await;
643        let mut previous = slot.entry.digest.lock().await;
644        let evidence = self.seal_evidence(action, decision, outcome, previous.clone())?;
645        let digest = evidence.receipt_digest.clone();
646        laser.emit_evidence(action, evidence).await?;
647        *previous = Some(digest);
648        Ok(())
649    }
650
651    // Build and seal one evidence record against the supplied predecessor.
652    fn seal_evidence(
653        &self,
654        action: &GovernedAction<'_>,
655        decision: &ActionDecision,
656        outcome: &'static str,
657        previous_digest: Option<String>,
658    ) -> Result<PolicyEvidence, LaserError> {
659        let approved_scope = match &decision.verdict {
660            Verdict::StepUp { scope } => Some(scope.clone()),
661            _ => None,
662        };
663        PolicyEvidence {
664            decision_id: ulid::Ulid::generate().to_string(),
665            decision: decision.verdict.as_str().to_owned(),
666            mode: self.mode.as_str().to_owned(),
667            kind: action.kind.as_str().to_owned(),
668            stream: action.stream.to_owned(),
669            topic: action.topic.to_owned(),
670            source: action.source.map(str::to_owned),
671            target: action.target.map(str::to_owned),
672            conversation: action.conversation.map(|id| id.to_string()),
673            correlation: action.correlation.map(str::to_owned),
674            operation: action.operation.map(str::to_owned),
675            tool: action.tool.map(str::to_owned),
676            on_behalf_of: action.on_behalf_of.map(str::to_owned),
677            reason: decision.reason.clone(),
678            approved_scope,
679            policy: decision.policy.clone(),
680            risk_score: decision.risk_score,
681            receipt_digest: String::new(),
682            previous_digest,
683            outcome: outcome.to_owned(),
684            at_micros: now_micros(),
685        }
686        .seal()
687    }
688}
689
690// One verdict applied under one mode: whether evidence is recorded, whether
691// and with what body the effect proceeds, and the denial when it does not.
692struct AppliedVerdict {
693    recorded: bool,
694    outcome: &'static str,
695    body: Option<Vec<u8>>,
696    denial: Option<LaserError>,
697}
698
699// The verdict-by-mode table. Observe mode records what enforcement would have
700// done and always proceeds unmodified.
701fn apply(mode: GovernorMode, verdict: Verdict) -> AppliedVerdict {
702    let enforced = matches!(mode, GovernorMode::Enforce);
703    match verdict {
704        Verdict::Allow => AppliedVerdict {
705            recorded: false,
706            outcome: "effected",
707            body: None,
708            denial: None,
709        },
710        Verdict::Observe => AppliedVerdict {
711            recorded: true,
712            outcome: "effected",
713            body: None,
714            denial: None,
715        },
716        Verdict::Modify { body } => AppliedVerdict {
717            recorded: true,
718            outcome: "effected",
719            body: enforced.then_some(body),
720            denial: None,
721        },
722        Verdict::Block => AppliedVerdict {
723            recorded: true,
724            outcome: if enforced { "blocked" } else { "effected" },
725            body: None,
726            denial: enforced
727                .then(|| LaserError::PolicyBlocked("the governor blocked this action".to_owned())),
728        },
729        Verdict::StepUp { scope } => AppliedVerdict {
730            recorded: true,
731            outcome: if enforced { "step_up" } else { "effected" },
732            body: None,
733            denial: enforced.then(|| LaserError::StepUpRequired { scope }),
734        },
735        Verdict::Defer => AppliedVerdict {
736            recorded: true,
737            outcome: if enforced { "deferred" } else { "effected" },
738            body: None,
739            denial: enforced.then(|| {
740                LaserError::PolicyDeferred("the governor deferred this action".to_owned())
741            }),
742        },
743    }
744}
745
746fn now_micros() -> u64 {
747    std::time::SystemTime::now()
748        .duration_since(std::time::UNIX_EPOCH)
749        .map(|elapsed| u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX))
750        .unwrap_or(0)
751}
752
753/// How a [`QuorumGovernor`] combines its voters' verdicts into one decision.
754/// Only [`Verdict::Allow`], [`Verdict::Observe`], and [`Verdict::Modify`] count
755/// as affirmative (the action would proceed under that voter alone).
756/// `Block`, `StepUp`, and `Defer` do not, regardless of quorum policy.
757#[derive(Debug, Clone, Copy, PartialEq, Eq)]
758pub enum QuorumPolicy {
759    /// Every voter must be affirmative.
760    All,
761    /// At least one voter must be affirmative.
762    Any,
763    /// At least `n` distinct voters must be affirmative.
764    AtLeast(usize),
765}
766
767impl QuorumPolicy {
768    fn met(self, affirmative: usize, total: usize) -> bool {
769        match self {
770            QuorumPolicy::All => affirmative == total,
771            QuorumPolicy::Any => affirmative >= 1,
772            QuorumPolicy::AtLeast(n) => affirmative >= n,
773        }
774    }
775}
776
777// One named voter in a QuorumGovernor. Mandatory voters are required
778// affirmatives, so an unavailable safety voter cannot be bypassed by `Any`.
779#[derive(Clone)]
780struct Voter {
781    name: String,
782    governor: Arc<dyn ActionGovernor>,
783    mandatory: bool,
784}
785
786/// A governor that composes independent voters under a [`QuorumPolicy`],
787/// itself an [`ActionGovernor`] so it enrolls with [`Laser::with_governor`]
788/// exactly like a single governor. Every voter runs concurrently over the same
789/// [`GovernedAction`]. Nothing here talks to a durable log: this is an
790/// in-process combinator, not the durable propose/vote/commit lifecycle a
791/// crash-safe, forgery-resistant version of this would need.
792///
793/// Affirmative verdicts ([`Verdict::Allow`], [`Verdict::Observe`],
794/// [`Verdict::Modify`]) count toward the quorum. [`Verdict::Block`],
795/// [`Verdict::StepUp`], and [`Verdict::Defer`] do not. Every mandatory voter
796/// must be affirmative before the quorum can pass. When the quorum is met, the
797/// composite verdict is the strongest affirmative found, in
798/// order `Modify` (a body replacement must be preserved) then `Observe` (any
799/// voter wanting evidence recorded wins) then `Allow`. When the quorum is not
800/// met, the composite is the most actionable denial found, in order `Block`
801/// then `StepUp` then `Defer`. Every voter's own verdict is recorded in the
802/// composite decision's `reason`, so the audit trail names who said what.
803#[derive(Clone)]
804pub struct QuorumGovernor {
805    voters: Vec<Voter>,
806    policy: QuorumPolicy,
807}
808
809impl QuorumGovernor {
810    /// A quorum under `policy`. Add voters with [`voter`](Self::voter). An
811    /// empty or otherwise invalid configuration blocks when evaluated.
812    pub fn new(policy: QuorumPolicy) -> Self {
813        Self {
814            voters: Vec::new(),
815            policy,
816        }
817    }
818
819    /// Enroll one named voter. A `mandatory` voter must return an affirmative
820    /// verdict before the action can proceed, regardless of `policy`.
821    #[must_use]
822    pub fn voter(
823        mut self,
824        name: impl Into<String>,
825        governor: Arc<dyn ActionGovernor>,
826        mandatory: bool,
827    ) -> Self {
828        self.voters.push(Voter {
829            name: name.into(),
830            governor,
831            mandatory,
832        });
833        self
834    }
835}
836
837#[async_trait]
838impl ActionGovernor for QuorumGovernor {
839    async fn decide(&self, action: &GovernedAction<'_>) -> Result<ActionDecision, LaserError> {
840        if self.voters.is_empty() {
841            return Ok(ActionDecision::block(
842                "quorum governor has no configured voters",
843            ));
844        }
845        if let QuorumPolicy::AtLeast(required) = self.policy
846            && (required == 0 || required > self.voters.len())
847        {
848            return Ok(ActionDecision::block(format!(
849                "quorum threshold {required} is invalid for {} voters",
850                self.voters.len()
851            )));
852        }
853        let mut names = std::collections::HashSet::with_capacity(self.voters.len());
854        if let Some(duplicate) = self
855            .voters
856            .iter()
857            .find(|voter| !names.insert(voter.name.as_str()))
858        {
859            return Ok(ActionDecision::block(format!(
860                "quorum voter '{}' is configured more than once",
861                duplicate.name
862            )));
863        }
864        let votes = futures::future::join_all(
865            self.voters
866                .iter()
867                .map(|voter| async move { (voter, voter.governor.decide(action).await) }),
868        )
869        .await;
870
871        let mut ballot = Vec::with_capacity(votes.len());
872        let mut affirmative = 0usize;
873        let mut mandatory_denial: Option<ActionDecision> = None;
874        let mut mandatory_error: Option<&str> = None;
875        let mut best_affirmative: Option<ActionDecision> = None;
876        let mut best_denial: Option<ActionDecision> = None;
877        let mut replacement: Option<&[u8]> = None;
878        let mut conflicting_replacements = false;
879        for (voter, vote) in &votes {
880            let decision = vote.as_ref().map_err(|error| error.to_string());
881            ballot.push(format!(
882                "{}={}",
883                voter.name,
884                decision
885                    .as_ref()
886                    .map(|decision| decision.verdict.as_str().to_owned())
887                    .unwrap_or_else(|error| format!("error({error})"))
888            ));
889            let Ok(decision) = vote else {
890                if voter.mandatory {
891                    mandatory_error = mandatory_error.or(Some(voter.name.as_str()));
892                }
893                continue;
894            };
895            if voter.mandatory && !is_affirmative(&decision.verdict) {
896                mandatory_denial = mandatory_denial.or(Some(decision.clone()));
897            }
898            if is_affirmative(&decision.verdict) {
899                affirmative += 1;
900                if let Verdict::Modify { body } = &decision.verdict {
901                    match replacement {
902                        None => replacement = Some(body),
903                        Some(existing) if existing != body => conflicting_replacements = true,
904                        Some(_) => {}
905                    }
906                }
907                if affirmative_rank(&decision.verdict)
908                    > best_affirmative
909                        .as_ref()
910                        .map_or(-1, |current| affirmative_rank(&current.verdict))
911                {
912                    best_affirmative = Some(decision.clone());
913                }
914            } else if denial_rank(&decision.verdict)
915                > best_denial
916                    .as_ref()
917                    .map_or(-1, |current| denial_rank(&current.verdict))
918            {
919                best_denial = Some(decision.clone());
920            }
921        }
922
923        // A non-mandatory voter error is an abstention. Mandatory errors block.
924        let reason = format!("quorum({:?}): {}", self.policy, ballot.join(", "));
925
926        if let Some(voter) = mandatory_error {
927            return Ok(annotate(
928                ActionDecision::block(format!("mandatory voter '{voter}' failed")),
929                &reason,
930            ));
931        }
932        if let Some(decision) = mandatory_denial {
933            return Ok(annotate(decision, &reason));
934        }
935        if conflicting_replacements {
936            return Ok(annotate(
937                ActionDecision::block("quorum voters proposed conflicting body replacements"),
938                &reason,
939            ));
940        }
941        if self.policy.met(affirmative, self.voters.len()) {
942            return Ok(annotate(
943                best_affirmative.unwrap_or_else(ActionDecision::allow),
944                &reason,
945            ));
946        }
947        Ok(annotate(
948            best_denial
949                .unwrap_or_else(|| ActionDecision::block("no voter reached the required quorum")),
950            &reason,
951        ))
952    }
953}
954
955fn annotate(mut decision: ActionDecision, ballot: &str) -> ActionDecision {
956    decision.reason = Some(match decision.reason.take() {
957        Some(detail) => format!("{detail}; {ballot}"),
958        None => ballot.to_owned(),
959    });
960    decision
961}
962
963fn is_affirmative(verdict: &Verdict) -> bool {
964    matches!(
965        verdict,
966        Verdict::Allow | Verdict::Observe | Verdict::Modify { .. }
967    )
968}
969
970// Modify > Observe > Allow: a body replacement must survive, evidence-wanting
971// is the next strongest signal, plain allow is the default.
972fn affirmative_rank(verdict: &Verdict) -> i8 {
973    match verdict {
974        Verdict::Modify { .. } => 2,
975        Verdict::Observe => 1,
976        Verdict::Allow => 0,
977        _ => -1,
978    }
979}
980
981// Block > StepUp > Defer: an outright rejection is the most actionable denial
982// to surface, a request for human approval is milder, holding for later is
983// mildest.
984fn denial_rank(verdict: &Verdict) -> i8 {
985    match verdict {
986        Verdict::Block => 2,
987        Verdict::StepUp { .. } => 1,
988        Verdict::Defer => 0,
989        _ => -1,
990    }
991}
992
993/// A governor whose active policy can be hot-swapped at runtime without
994/// dropping clones already enrolled via [`Laser::with_governor`] or
995/// restarting the process. [`swap`](Self::swap) can be driven by anything: an
996/// operator call, a config reload, or a caller folding a policy-update topic
997/// and swapping in the governor that matches the latest fact. A swap only
998/// changes which policy the *next* [`decide`](Self::decide) call runs under:
999/// it never reinterprets a [`PolicyEvidence`] record already on the log, the
1000/// same non-retroactive rule [`crate::intent::Intent::policy_version`] gives
1001/// durable intents.
1002pub struct SwappableGovernor {
1003    active: std::sync::RwLock<Arc<dyn ActionGovernor>>,
1004}
1005
1006impl SwappableGovernor {
1007    /// A swappable governor starting from `initial`.
1008    pub fn new(initial: Arc<dyn ActionGovernor>) -> Self {
1009        Self {
1010            active: std::sync::RwLock::new(initial),
1011        }
1012    }
1013
1014    /// Replace the active policy with `next`, returning the one just
1015    /// replaced. A `decide` already in flight finishes under whichever
1016    /// policy it read. This never cancels or reruns in-flight work.
1017    pub fn swap(&self, next: Arc<dyn ActionGovernor>) -> Arc<dyn ActionGovernor> {
1018        let mut active = self
1019            .active
1020            .write()
1021            .expect("governor lock is never poisoned");
1022        std::mem::replace(&mut active, next)
1023    }
1024
1025    /// The currently active policy.
1026    pub fn current(&self) -> Arc<dyn ActionGovernor> {
1027        Arc::clone(&self.active.read().expect("governor lock is never poisoned"))
1028    }
1029}
1030
1031#[async_trait]
1032impl ActionGovernor for SwappableGovernor {
1033    async fn decide(&self, action: &GovernedAction<'_>) -> Result<ActionDecision, LaserError> {
1034        self.current().decide(action).await
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    #[test]
1043    fn given_a_block_decision_when_built_then_should_carry_the_reason_and_refinements() {
1044        let decision = ActionDecision::block("wire transfers need approval")
1045            .with_policy(PolicyRef {
1046                pack_id: "finance".to_owned(),
1047                pack_version: "3".to_owned(),
1048                rule_ids: vec!["no-wires".to_owned()],
1049            })
1050            .with_risk_score(0.9);
1051        assert_eq!(decision.verdict, Verdict::Block);
1052        assert_eq!(
1053            decision.reason.as_deref(),
1054            Some("wire transfers need approval")
1055        );
1056        assert_eq!(
1057            decision.policy.as_ref().map(|p| p.pack_id.as_str()),
1058            Some("finance")
1059        );
1060        assert_eq!(decision.risk_score, Some(0.9));
1061    }
1062
1063    #[test]
1064    fn given_enforce_mode_when_verdicts_applied_then_should_map_the_full_table() {
1065        let allow = apply(GovernorMode::Enforce, Verdict::Allow);
1066        assert!(!allow.recorded && allow.denial.is_none() && allow.body.is_none());
1067
1068        let observe = apply(GovernorMode::Enforce, Verdict::Observe);
1069        assert!(observe.recorded && observe.denial.is_none());
1070        assert_eq!(observe.outcome, "effected");
1071
1072        let modify = apply(
1073            GovernorMode::Enforce,
1074            Verdict::Modify {
1075                body: b"x".to_vec(),
1076            },
1077        );
1078        assert_eq!(modify.body.as_deref(), Some(b"x".as_slice()));
1079
1080        let block = apply(GovernorMode::Enforce, Verdict::Block);
1081        assert_eq!(block.outcome, "blocked");
1082        assert!(matches!(block.denial, Some(LaserError::PolicyBlocked(_))));
1083
1084        let step_up = apply(
1085            GovernorMode::Enforce,
1086            Verdict::StepUp {
1087                scope: "storage:rotate".to_owned(),
1088            },
1089        );
1090        assert_eq!(step_up.outcome, "step_up");
1091        assert!(
1092            matches!(step_up.denial, Some(LaserError::StepUpRequired { scope }) if scope == "storage:rotate")
1093        );
1094
1095        let defer = apply(GovernorMode::Enforce, Verdict::Defer);
1096        assert_eq!(defer.outcome, "deferred");
1097        assert!(matches!(defer.denial, Some(LaserError::PolicyDeferred(_))));
1098    }
1099
1100    #[test]
1101    fn given_observe_mode_when_verdicts_applied_then_should_record_and_always_proceed() {
1102        for verdict in [
1103            Verdict::Observe,
1104            Verdict::Block,
1105            Verdict::StepUp {
1106                scope: "s".to_owned(),
1107            },
1108            Verdict::Modify {
1109                body: b"x".to_vec(),
1110            },
1111            Verdict::Defer,
1112        ] {
1113            let applied = apply(GovernorMode::Observe, verdict);
1114            assert!(applied.recorded);
1115            assert!(applied.denial.is_none());
1116            assert!(applied.body.is_none(), "shadow mode never modifies");
1117            assert_eq!(applied.outcome, "effected");
1118        }
1119    }
1120
1121    #[test]
1122    fn given_sealed_evidence_when_re_encoded_without_its_digest_then_should_reproduce_the_digest() {
1123        let evidence = sample_evidence().seal().expect("seals");
1124        let mut unsealed = evidence.clone();
1125        unsealed.receipt_digest = String::new();
1126        let canonical = unsealed.encode().expect("encodes");
1127        assert_eq!(
1128            evidence.receipt_digest,
1129            blake3::hash(&canonical).to_string()
1130        );
1131        assert_eq!(evidence.receipt_digest.len(), 64);
1132    }
1133
1134    #[test]
1135    fn given_evidence_when_round_tripped_through_cbor_then_should_decode_identically() {
1136        let evidence = sample_evidence().seal().expect("seals");
1137        let decoded =
1138            PolicyEvidence::decode(&evidence.encode().expect("encodes")).expect("decodes");
1139        assert_eq!(decoded, evidence);
1140    }
1141
1142    #[test]
1143    fn given_consecutive_decisions_in_one_conversation_when_sealed_then_should_chain_digests() {
1144        let state = GovernorState::new(Arc::new(AllowAll), GovernorMode::Enforce);
1145        let conversation = ConversationId::new();
1146        let payload = b"body";
1147        let action = GovernedAction {
1148            kind: ActionKind::Send,
1149            stream: "laser",
1150            topic: "agent.sessions",
1151            source: Some("planner"),
1152            target: None,
1153            conversation: Some(conversation),
1154            correlation: None,
1155            operation: None,
1156            tool: None,
1157            on_behalf_of: None,
1158            purpose: None,
1159            data_classification: None,
1160            payload,
1161            signed: false,
1162            counters: ActionCounters::default(),
1163        };
1164        let first = state
1165            .seal_evidence(&action, &ActionDecision::observe(), "effected", None)
1166            .expect("seals");
1167        assert_eq!(first.previous_digest, None);
1168        let second = state
1169            .seal_evidence(
1170                &action,
1171                &ActionDecision::block("no"),
1172                "blocked",
1173                Some(first.receipt_digest.clone()),
1174            )
1175            .expect("seals");
1176        assert_eq!(
1177            second.previous_digest.as_deref(),
1178            Some(first.receipt_digest.as_str())
1179        );
1180    }
1181
1182    #[test]
1183    fn given_a_chain_when_reordered_or_edited_then_should_fail_verification() {
1184        let first = sample_evidence().seal().expect("seals");
1185        let mut second = sample_evidence();
1186        second.decision_id = "01J0000000000000000000002".to_owned();
1187        second.previous_digest = Some(first.receipt_digest.clone());
1188        let second = second.seal().expect("seals");
1189        assert!(verify_evidence_chain(&[first.clone(), second.clone()]));
1190        assert!(verify_evidence_chain(&[]));
1191        assert!(!verify_evidence_chain(&[second.clone(), first.clone()]));
1192        assert!(!verify_evidence_chain(std::slice::from_ref(&second)));
1193        let mut edited = second;
1194        edited.reason = Some("edited".to_owned());
1195        assert!(!verify_evidence_chain(&[first, edited]));
1196    }
1197
1198    #[tokio::test]
1199    async fn given_a_full_chain_table_when_a_slot_is_released_then_should_admit_without_growing() {
1200        let state = Arc::new(GovernorState::with_retention(
1201            Arc::new(AllowAll),
1202            GovernorMode::Enforce,
1203            GovernorRetention {
1204                capacity: 1,
1205                idle_ttl: std::time::Duration::from_secs(60),
1206            },
1207        ));
1208        let first = state.acquire_chain(Some(1)).await;
1209        let waiting_state = Arc::clone(&state);
1210        let waiting = tokio::spawn(async move { waiting_state.acquire_chain(Some(2)).await });
1211        tokio::task::yield_now().await;
1212
1213        assert!(!waiting.is_finished());
1214        assert_eq!(state.chain.lock().await.len(), 1);
1215
1216        drop(first);
1217        let second = waiting.await.expect("chain admission task succeeds");
1218        assert_eq!(state.chain.lock().await.len(), 1);
1219        assert!(state.chain.lock().await.contains_key(&Some(2)));
1220        drop(second);
1221    }
1222
1223    #[tokio::test]
1224    async fn given_an_expired_chain_head_when_reacquired_then_should_start_a_new_local_chain() {
1225        let state = GovernorState::with_retention(
1226            Arc::new(AllowAll),
1227            GovernorMode::Enforce,
1228            GovernorRetention {
1229                capacity: 1,
1230                idle_ttl: std::time::Duration::ZERO,
1231            },
1232        );
1233        let first = state.acquire_chain(Some(1)).await;
1234        *first.entry.digest.lock().await = Some("old-head".to_owned());
1235        drop(first);
1236
1237        let second = state.acquire_chain(Some(1)).await;
1238        assert_eq!(*second.entry.digest.lock().await, None);
1239    }
1240
1241    struct AllowAll;
1242
1243    #[async_trait]
1244    impl ActionGovernor for AllowAll {
1245        async fn decide(&self, _: &GovernedAction<'_>) -> Result<ActionDecision, LaserError> {
1246            Ok(ActionDecision::allow())
1247        }
1248    }
1249
1250    fn sample_evidence() -> PolicyEvidence {
1251        PolicyEvidence {
1252            decision_id: "01J0000000000000000000000".to_owned(),
1253            decision: "block".to_owned(),
1254            mode: "enforce".to_owned(),
1255            kind: "command".to_owned(),
1256            stream: "laser".to_owned(),
1257            topic: "agent.sessions".to_owned(),
1258            source: Some("planner".to_owned()),
1259            target: Some("worker".to_owned()),
1260            conversation: None,
1261            correlation: Some("corr-1".to_owned()),
1262            operation: Some("chat".to_owned()),
1263            tool: None,
1264            on_behalf_of: Some("user-1".to_owned()),
1265            reason: Some("blocked by policy".to_owned()),
1266            approved_scope: None,
1267            policy: Some(PolicyRef {
1268                pack_id: "base".to_owned(),
1269                pack_version: "1".to_owned(),
1270                rule_ids: vec!["r1".to_owned()],
1271            }),
1272            risk_score: Some(0.5),
1273            receipt_digest: String::new(),
1274            previous_digest: None,
1275            outcome: "blocked".to_owned(),
1276            at_micros: 1_717_171_777_000_000,
1277        }
1278    }
1279
1280    struct FixedVerdict(Verdict);
1281
1282    #[async_trait]
1283    impl ActionGovernor for FixedVerdict {
1284        async fn decide(&self, _: &GovernedAction<'_>) -> Result<ActionDecision, LaserError> {
1285            let mut decision = ActionDecision::of(self.0.clone());
1286            decision.reason = Some("fixed".to_owned());
1287            Ok(decision)
1288        }
1289    }
1290
1291    struct AlwaysErrors;
1292
1293    #[async_trait]
1294    impl ActionGovernor for AlwaysErrors {
1295        async fn decide(&self, _: &GovernedAction<'_>) -> Result<ActionDecision, LaserError> {
1296            Err(LaserError::Invalid("voter unavailable".to_owned()))
1297        }
1298    }
1299
1300    fn voter(verdict: Verdict) -> Arc<dyn ActionGovernor> {
1301        Arc::new(FixedVerdict(verdict))
1302    }
1303
1304    fn probe_action() -> GovernedAction<'static> {
1305        GovernedAction {
1306            kind: ActionKind::Send,
1307            stream: "laser",
1308            topic: "agent.sessions",
1309            source: Some("planner"),
1310            target: None,
1311            conversation: None,
1312            correlation: None,
1313            operation: None,
1314            tool: None,
1315            on_behalf_of: None,
1316            purpose: None,
1317            data_classification: None,
1318            payload: b"body",
1319            signed: false,
1320            counters: ActionCounters::default(),
1321        }
1322    }
1323
1324    #[tokio::test]
1325    async fn given_every_voter_allows_under_any_policy_when_decided_then_should_allow() {
1326        let quorum = QuorumGovernor::new(QuorumPolicy::Any)
1327            .voter("a", voter(Verdict::Allow), false)
1328            .voter("b", voter(Verdict::Allow), false);
1329        let decision = quorum.decide(&probe_action()).await.expect("decides");
1330        assert_eq!(decision.verdict, Verdict::Allow);
1331    }
1332
1333    #[tokio::test]
1334    async fn given_a_mandatory_voter_blocks_when_decided_then_should_block_regardless_of_policy() {
1335        let quorum = QuorumGovernor::new(QuorumPolicy::Any)
1336            .voter("safety", voter(Verdict::Block), true)
1337            .voter("llm", voter(Verdict::Allow), false);
1338        let decision = quorum.decide(&probe_action()).await.expect("decides");
1339        assert_eq!(decision.verdict, Verdict::Block);
1340        assert!(decision.reason.as_deref().unwrap().contains("safety"));
1341    }
1342
1343    #[tokio::test]
1344    async fn given_all_policy_with_one_non_mandatory_block_when_decided_then_should_deny() {
1345        let quorum = QuorumGovernor::new(QuorumPolicy::All)
1346            .voter("a", voter(Verdict::Allow), false)
1347            .voter("b", voter(Verdict::Block), false);
1348        let decision = quorum.decide(&probe_action()).await.expect("decides");
1349        assert_eq!(decision.verdict, Verdict::Block);
1350    }
1351
1352    #[tokio::test]
1353    async fn given_any_policy_with_one_affirmative_among_blocks_when_decided_then_should_allow() {
1354        let quorum = QuorumGovernor::new(QuorumPolicy::Any)
1355            .voter("a", voter(Verdict::Block), false)
1356            .voter("b", voter(Verdict::Block), false)
1357            .voter("c", voter(Verdict::Allow), false);
1358        let decision = quorum.decide(&probe_action()).await.expect("decides");
1359        assert_eq!(decision.verdict, Verdict::Allow);
1360    }
1361
1362    #[tokio::test]
1363    async fn given_at_least_two_policy_when_only_one_voter_affirms_then_should_deny() {
1364        let quorum = QuorumGovernor::new(QuorumPolicy::AtLeast(2))
1365            .voter("a", voter(Verdict::Allow), false)
1366            .voter("b", voter(Verdict::Block), false)
1367            .voter("c", voter(Verdict::Block), false);
1368        let decision = quorum.decide(&probe_action()).await.expect("decides");
1369        assert_eq!(decision.verdict, Verdict::Block);
1370    }
1371
1372    #[tokio::test]
1373    async fn given_at_least_two_policy_when_two_voters_affirm_then_should_allow() {
1374        let quorum = QuorumGovernor::new(QuorumPolicy::AtLeast(2))
1375            .voter("a", voter(Verdict::Allow), false)
1376            .voter("b", voter(Verdict::Observe), false)
1377            .voter("c", voter(Verdict::Block), false);
1378        let decision = quorum.decide(&probe_action()).await.expect("decides");
1379        assert_ne!(decision.verdict, Verdict::Block);
1380    }
1381
1382    #[tokio::test]
1383    async fn given_a_modify_voter_among_affirmatives_when_quorum_met_then_should_apply_the_body() {
1384        let quorum = QuorumGovernor::new(QuorumPolicy::All)
1385            .voter("a", voter(Verdict::Allow), false)
1386            .voter(
1387                "redactor",
1388                voter(Verdict::Modify {
1389                    body: b"redacted".to_vec(),
1390                }),
1391                false,
1392            );
1393        let decision = quorum.decide(&probe_action()).await.expect("decides");
1394        assert_eq!(
1395            decision.verdict,
1396            Verdict::Modify {
1397                body: b"redacted".to_vec()
1398            }
1399        );
1400    }
1401
1402    #[tokio::test]
1403    async fn given_no_quorum_with_a_step_up_and_a_defer_when_decided_then_should_prefer_step_up() {
1404        let quorum = QuorumGovernor::new(QuorumPolicy::All)
1405            .voter(
1406                "reviewer",
1407                voter(Verdict::StepUp {
1408                    scope: "storage:rotate".to_owned(),
1409                }),
1410                false,
1411            )
1412            .voter("scheduler", voter(Verdict::Defer), false);
1413        let decision = quorum.decide(&probe_action()).await.expect("decides");
1414        assert!(matches!(decision.verdict, Verdict::StepUp { .. }));
1415    }
1416
1417    #[tokio::test]
1418    async fn given_no_voters_when_decided_then_should_block() {
1419        let quorum = QuorumGovernor::new(QuorumPolicy::All);
1420        let decision = quorum.decide(&probe_action()).await.expect("decides");
1421        assert_eq!(decision.verdict, Verdict::Block);
1422    }
1423
1424    #[tokio::test]
1425    async fn given_an_invalid_threshold_when_decided_then_should_block() {
1426        let quorum =
1427            QuorumGovernor::new(QuorumPolicy::AtLeast(0)).voter("a", voter(Verdict::Allow), false);
1428        let decision = quorum.decide(&probe_action()).await.expect("decides");
1429        assert_eq!(decision.verdict, Verdict::Block);
1430    }
1431
1432    #[tokio::test]
1433    async fn given_duplicate_voter_names_when_decided_then_should_block() {
1434        let quorum = QuorumGovernor::new(QuorumPolicy::Any)
1435            .voter("same", voter(Verdict::Allow), false)
1436            .voter("same", voter(Verdict::Allow), false);
1437        let decision = quorum.decide(&probe_action()).await.expect("decides");
1438        assert_eq!(decision.verdict, Verdict::Block);
1439    }
1440
1441    #[tokio::test]
1442    async fn given_a_mandatory_step_up_when_another_allows_then_should_step_up() {
1443        let quorum = QuorumGovernor::new(QuorumPolicy::Any)
1444            .voter(
1445                "safety",
1446                voter(Verdict::StepUp {
1447                    scope: "storage:rotate".to_owned(),
1448                }),
1449                true,
1450            )
1451            .voter("llm", voter(Verdict::Allow), false);
1452        let decision = quorum.decide(&probe_action()).await.expect("decides");
1453        assert!(matches!(decision.verdict, Verdict::StepUp { .. }));
1454    }
1455
1456    #[tokio::test]
1457    async fn given_a_mandatory_error_when_another_allows_then_should_block() {
1458        let quorum = QuorumGovernor::new(QuorumPolicy::Any)
1459            .voter("safety", Arc::new(AlwaysErrors), true)
1460            .voter("llm", voter(Verdict::Allow), false);
1461        let decision = quorum.decide(&probe_action()).await.expect("decides");
1462        assert_eq!(decision.verdict, Verdict::Block);
1463    }
1464
1465    #[tokio::test]
1466    async fn given_conflicting_modifications_when_quorum_met_then_should_block() {
1467        let quorum = QuorumGovernor::new(QuorumPolicy::All)
1468            .voter(
1469                "one",
1470                voter(Verdict::Modify {
1471                    body: b"one".to_vec(),
1472                }),
1473                false,
1474            )
1475            .voter(
1476                "two",
1477                voter(Verdict::Modify {
1478                    body: b"two".to_vec(),
1479                }),
1480                false,
1481            );
1482        let decision = quorum.decide(&probe_action()).await.expect("decides");
1483        assert_eq!(decision.verdict, Verdict::Block);
1484    }
1485
1486    #[tokio::test]
1487    async fn given_an_erroring_voter_when_decided_then_should_not_veto_the_others() {
1488        let quorum = QuorumGovernor::new(QuorumPolicy::Any)
1489            .voter("flaky", Arc::new(AlwaysErrors), false)
1490            .voter("steady", voter(Verdict::Allow), false);
1491        let decision = quorum.decide(&probe_action()).await.expect("decides");
1492        assert_eq!(decision.verdict, Verdict::Allow);
1493        assert!(decision.reason.as_deref().unwrap().contains("error("));
1494    }
1495
1496    #[tokio::test]
1497    async fn given_an_erroring_voter_alone_when_decided_then_should_deny_for_lack_of_quorum() {
1498        let quorum =
1499            QuorumGovernor::new(QuorumPolicy::Any).voter("flaky", Arc::new(AlwaysErrors), false);
1500        let decision = quorum.decide(&probe_action()).await.expect("decides");
1501        assert_eq!(decision.verdict, Verdict::Block);
1502    }
1503
1504    #[tokio::test]
1505    async fn given_a_swappable_governor_when_decided_then_should_run_the_active_policy() {
1506        let swappable = SwappableGovernor::new(voter(Verdict::Allow));
1507        let decision = swappable.decide(&probe_action()).await.expect("decides");
1508        assert_eq!(decision.verdict, Verdict::Allow);
1509    }
1510
1511    #[tokio::test]
1512    async fn given_a_policy_swap_when_decided_again_then_should_run_the_new_policy() {
1513        let swappable = SwappableGovernor::new(voter(Verdict::Allow));
1514        swappable.swap(voter(Verdict::Block));
1515        let decision = swappable.decide(&probe_action()).await.expect("decides");
1516        assert_eq!(decision.verdict, Verdict::Block);
1517    }
1518
1519    #[tokio::test]
1520    async fn given_a_swap_when_applied_then_should_return_the_previous_policy() {
1521        let swappable = SwappableGovernor::new(voter(Verdict::Allow));
1522        let previous = swappable.swap(voter(Verdict::Block));
1523        let previous_decision = previous.decide(&probe_action()).await.expect("decides");
1524        assert_eq!(previous_decision.verdict, Verdict::Allow);
1525        let current_decision = swappable
1526            .current()
1527            .decide(&probe_action())
1528            .await
1529            .expect("decides");
1530        assert_eq!(current_decision.verdict, Verdict::Block);
1531    }
1532
1533    #[tokio::test]
1534    async fn given_clones_sharing_one_swappable_governor_when_one_swaps_then_all_should_observe_it()
1535    {
1536        let swappable = Arc::new(SwappableGovernor::new(voter(Verdict::Allow)));
1537        let clone = Arc::clone(&swappable);
1538        clone.swap(voter(Verdict::Block));
1539        let decision = swappable.decide(&probe_action()).await.expect("decides");
1540        assert_eq!(decision.verdict, Verdict::Block);
1541    }
1542}