Skip to main content

chio_kernel/
approval.rs

1//! Phase 3.4-3.6 human-in-the-loop (HITL) primitives.
2//!
3//! This module houses the approval-request data model, the persistent
4//! approval-store contract, the approval guard that decides when a call
5//! needs human sign-off, and the async resume entry points used by the
6//! HTTP surface after a human responds. The design follows
7//! `docs/protocols/HUMAN-IN-THE-LOOP-PROTOCOL.md`.
8//!
9//! Scope note (deviation documented in the phase report): the existing
10//! `crate::runtime::Verdict` is `Copy` and threaded through 5,000+ lines
11//! of kernel code. Rather than ripple a breaking change through every
12//! call site, this module exposes a richer [`HitlVerdict`] that carries
13//! the pending approval request when one is needed. The public
14//! `Verdict` enum still gains a `PendingApproval` marker variant so
15//! external callers can pattern-match on the three-way decision; the
16//! payload is returned separately via [`ApprovalGuard::evaluate`] and
17//! [`ChioKernel::evaluate_tool_call_with_hitl`](crate::ChioKernel).
18
19use std::collections::HashMap;
20use std::sync::{Mutex, RwLock};
21
22use chio_core::capability::{
23    Constraint, GovernedApprovalDecision, GovernedApprovalToken, GovernedAutonomyTier,
24    GovernedTransactionIntent, MonetaryAmount,
25};
26use chio_core::crypto::{sha256_hex, PublicKey};
27use serde::{Deserialize, Serialize};
28
29use crate::runtime::{ToolCallRequest, Verdict};
30use crate::{AgentId, KernelError, ServerId};
31
32/// Maximum lifetime (in seconds) permitted on a single approval token.
33/// Mirrors the `MAX_APPROVAL_TTL_SECS` documented in the HITL protocol
34/// section 15: the single-use replay registry's TTL is pinned to this
35/// value so no token can outlive its replay entry.
36pub const MAX_APPROVAL_TTL_SECS: u64 = 3600;
37
38/// A request for human approval, produced when the approval guard
39/// returns `Verdict::PendingApproval`. Designed to be serialized into
40/// the approval store and the webhook payload without further wrapping.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct ApprovalRequest {
43    /// Unique request identifier. Caller-stable so the approval store
44    /// can be keyed on this value. In production this is a UUIDv7.
45    pub approval_id: String,
46
47    /// The policy / grant identifier that triggered the approval.
48    pub policy_id: String,
49
50    /// The calling agent's identifier.
51    pub subject_id: AgentId,
52
53    /// Capability token ID bound to this request.
54    pub capability_id: String,
55
56    /// Public key of the capability subject this approval is bound to.
57    /// A presented approval token must carry the same subject.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub subject_public_key: Option<PublicKey>,
60
61    /// Server hosting the target tool.
62    pub tool_server: ServerId,
63
64    /// Tool being invoked.
65    pub tool_name: String,
66
67    /// Short action verb for human summaries (e.g. `invoke`, `charge`).
68    pub action: String,
69
70    /// SHA-256 hex digest of the canonical JSON of the tool arguments
71    /// / governed intent. Used to bind an approval token to this exact
72    /// parameter set; a mutated argument payload will not satisfy the
73    /// same approval.
74    pub parameter_hash: String,
75
76    /// Unix seconds after which the request auto-denies (or escalates,
77    /// per `timeout_action` in the grant).
78    pub expires_at: u64,
79
80    /// Hint for channels about where the human can respond (e.g. the
81    /// URL of the dashboard or a Slack permalink). `None` means
82    /// "dispatcher will fill this in after sending".
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub callback_hint: Option<String>,
85
86    /// Unix seconds when the request was created.
87    pub created_at: u64,
88
89    /// Short human-readable summary for dashboards.
90    pub summary: String,
91
92    /// Original governed intent, when one is bound. Required for
93    /// threshold-based approvals so the approver sees the financial
94    /// envelope they are signing off on.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub governed_intent: Option<GovernedTransactionIntent>,
97
98    /// Public keys allowed to approve this request. The kernel fails
99    /// closed when the set is empty or when the presented approver is
100    /// not in the set.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub trusted_approvers: Vec<PublicKey>,
103
104    /// Guards that triggered the approval requirement.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub triggered_by: Vec<String>,
107}
108
109/// Minimal approval decision recorded after a human responds.
110///
111/// Callers construct this from the HTTP `POST /approvals/{id}/respond`
112/// payload. It is an in-process marker; the cryptographic artifact is
113/// the `GovernedApprovalToken` that rides alongside it.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(rename_all = "snake_case")]
116pub enum ApprovalOutcome {
117    Approved,
118    Denied,
119}
120
121/// Decision packet delivered by an approver.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ApprovalDecision {
124    /// Approval request this decision answers.
125    pub approval_id: String,
126    /// Outcome (approved / denied).
127    pub outcome: ApprovalOutcome,
128    /// Optional free-form reason supplied by the approver.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub reason: Option<String>,
131    /// Public key of the approver. Used to validate the token signature
132    /// and for non-repudiation in the receipt.
133    pub approver: PublicKey,
134    /// Signed approval token produced by the approver.
135    pub token: GovernedApprovalToken,
136    /// Unix seconds when the kernel received this decision.
137    pub received_at: u64,
138}
139
140/// Lightweight "approval token" representation used inside the kernel.
141/// For HITL v1 this wraps the existing `GovernedApprovalToken` together
142/// with the approval request it satisfies, so consumers do not have to
143/// re-plumb the full governance type through every surface.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ApprovalToken {
146    pub approval_id: String,
147    pub governed_token: GovernedApprovalToken,
148    pub approver: PublicKey,
149}
150
151impl ApprovalToken {
152    /// Build an `ApprovalToken` from a decision packet.
153    #[must_use]
154    pub fn from_decision(decision: &ApprovalDecision) -> Self {
155        Self {
156            approval_id: decision.approval_id.clone(),
157            governed_token: decision.token.clone(),
158            approver: decision.approver.clone(),
159        }
160    }
161
162    /// Verify the token's cryptographic signature and binding against
163    /// the original approval request. Returns `Err(KernelError::ApprovalRejected)`
164    /// when any check fails.
165    pub fn verify_against(
166        &self,
167        request: &ApprovalRequest,
168        now: u64,
169    ) -> Result<GovernedApprovalDecision, KernelError> {
170        // Binding checks: request_id, intent hash, approver identity.
171        if self.governed_token.request_id != request.approval_id {
172            return Err(KernelError::ApprovalRejected(
173                "approval token bound to a different request".into(),
174            ));
175        }
176        if self.governed_token.governed_intent_hash != request.parameter_hash {
177            return Err(KernelError::ApprovalRejected(
178                "approval token bound to a different parameter set".into(),
179            ));
180        }
181        if self.governed_token.approver != self.approver {
182            return Err(KernelError::ApprovalRejected(
183                "approval token approver mismatch".into(),
184            ));
185        }
186        if request.trusted_approvers.is_empty() {
187            return Err(KernelError::ApprovalRejected(
188                "approval request does not declare any trusted approvers".into(),
189            ));
190        }
191        if !request.trusted_approvers.contains(&self.approver) {
192            return Err(KernelError::ApprovalRejected(
193                "approval token approver is not trusted for this request".into(),
194            ));
195        }
196        match request.subject_public_key.as_ref() {
197            Some(expected_subject) if &self.governed_token.subject != expected_subject => {
198                return Err(KernelError::ApprovalRejected(
199                    "approval token subject does not match the request subject".into(),
200                ));
201            }
202            Some(_) => {}
203            None if self.governed_token.subject.to_hex() != request.subject_id => {
204                return Err(KernelError::ApprovalRejected(
205                    "approval request is missing a subject binding".into(),
206                ));
207            }
208            None => {}
209        }
210
211        // Time bounds.
212        if now >= self.governed_token.expires_at {
213            return Err(KernelError::ApprovalRejected(
214                "approval token has expired".into(),
215            ));
216        }
217        if now < self.governed_token.issued_at {
218            return Err(KernelError::ApprovalRejected(
219                "approval token not yet valid".into(),
220            ));
221        }
222
223        // Lifetime cap: a token whose lifetime exceeds MAX_APPROVAL_TTL_SECS
224        // cannot be safely tracked in the single-use replay registry.
225        let lifetime = self
226            .governed_token
227            .expires_at
228            .saturating_sub(self.governed_token.issued_at);
229        if lifetime > MAX_APPROVAL_TTL_SECS {
230            return Err(KernelError::ApprovalRejected(format!(
231                "approval token lifetime {lifetime}s exceeds cap {MAX_APPROVAL_TTL_SECS}s"
232            )));
233        }
234
235        // Signature.
236        let ok = self.governed_token.verify_signature().map_err(|e| {
237            KernelError::ApprovalRejected(format!(
238                "approval token signature verification failed: {e}"
239            ))
240        })?;
241        if !ok {
242            return Err(KernelError::ApprovalRejected(
243                "approval token signature did not verify".into(),
244            ));
245        }
246
247        Ok(self.governed_token.decision)
248    }
249}
250
251/// Errors emitted by approval stores.
252#[derive(Debug, thiserror::Error)]
253pub enum ApprovalStoreError {
254    #[error("approval request not found: {0}")]
255    NotFound(String),
256    #[error("approval already resolved: {0}")]
257    AlreadyResolved(String),
258    #[error("approval token already consumed (replay detected): {0}")]
259    Replay(String),
260    #[error("storage backend error: {0}")]
261    Backend(String),
262    #[error("serialization error: {0}")]
263    Serialization(String),
264}
265
266/// Filter for `list_pending`.
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
268pub struct ApprovalFilter {
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub subject_id: Option<String>,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub tool_server: Option<String>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub tool_name: Option<String>,
275    /// Only include requests whose `expires_at` is greater than this.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub not_expired_at: Option<u64>,
278    /// Maximum number of rows to return.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub limit: Option<usize>,
281}
282
283/// Resolved-approval row retained for audit and replay protection.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct ResolvedApproval {
286    pub approval_id: String,
287    pub outcome: ApprovalOutcome,
288    pub resolved_at: u64,
289    pub approver_hex: String,
290    pub token_id: String,
291}
292
293/// Persistent store for pending and resolved HITL approvals. The trait
294/// is intentionally synchronous because every concrete implementation
295/// in the kernel hot path today (in-memory, SQLite via `rusqlite`) is
296/// synchronous, and the kernel itself does not run on an async
297/// executor.
298pub trait ApprovalStore: Send + Sync {
299    /// Persist a new pending request. Idempotent on `approval_id`: a
300    /// second call with the same id returns without error as long as
301    /// the stored payload matches.
302    fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError>;
303
304    /// Fetch a single pending approval by id.
305    fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError>;
306
307    /// List all pending approvals matching the filter.
308    fn list_pending(
309        &self,
310        filter: &ApprovalFilter,
311    ) -> Result<Vec<ApprovalRequest>, ApprovalStoreError>;
312
313    /// Mark a pending approval as resolved. Returns
314    /// `ApprovalStoreError::AlreadyResolved` if the request has already
315    /// been resolved (double-resolve protection) and
316    /// `ApprovalStoreError::Replay` if the bound token has already been
317    /// consumed on a different request.
318    fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError>;
319
320    /// Count approved calls for a given subject / grant pair. Used by
321    /// `Constraint::RequireApprovalAbove` threshold accounting.
322    fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError>;
323
324    /// Record that a token (by `token_id` and `parameter_hash`) has
325    /// been consumed. Used to reject replays of the same approval
326    /// token across a restart. Implementations may also call this from
327    /// [`resolve`]; exposing it on the trait lets the kernel do the
328    /// replay check before persisting the resolution, which matters
329    /// when the store is backed by SQLite and wants to run the check
330    /// inside the transaction.
331    fn record_consumed(
332        &self,
333        token_id: &str,
334        parameter_hash: &str,
335        now: u64,
336    ) -> Result<(), ApprovalStoreError>;
337
338    /// Returns `true` if the token has already been consumed.
339    fn is_consumed(&self, token_id: &str, parameter_hash: &str)
340        -> Result<bool, ApprovalStoreError>;
341
342    /// Fetch the resolution record for a previously resolved approval.
343    fn get_resolution(&self, id: &str) -> Result<Option<ResolvedApproval>, ApprovalStoreError>;
344}
345
346/// Batch approvals let a human pre-approve a class of calls.
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
348pub struct BatchApproval {
349    pub batch_id: String,
350    pub approver_hex: String,
351    pub subject_id: AgentId,
352    pub server_pattern: String,
353    pub tool_pattern: String,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub max_amount_per_call: Option<MonetaryAmount>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub max_total_amount: Option<MonetaryAmount>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub max_calls: Option<u32>,
360    pub not_before: u64,
361    pub not_after: u64,
362    #[serde(default)]
363    pub used_calls: u32,
364    #[serde(default)]
365    pub used_total_units: u64,
366    #[serde(default)]
367    pub revoked: bool,
368}
369
370/// Store for batch approvals. Counterpart to `ApprovalStore`.
371pub trait BatchApprovalStore: Send + Sync {
372    fn store(&self, batch: &BatchApproval) -> Result<(), ApprovalStoreError>;
373
374    fn find_matching(
375        &self,
376        subject_id: &str,
377        server_id: &str,
378        tool_name: &str,
379        amount: Option<&MonetaryAmount>,
380        now: u64,
381    ) -> Result<Option<BatchApproval>, ApprovalStoreError>;
382
383    fn record_usage(
384        &self,
385        batch_id: &str,
386        amount: Option<&MonetaryAmount>,
387    ) -> Result<(), ApprovalStoreError>;
388
389    fn revoke(&self, batch_id: &str) -> Result<(), ApprovalStoreError>;
390
391    fn get(&self, batch_id: &str) -> Result<Option<BatchApproval>, ApprovalStoreError>;
392}
393
394/// Contract a channel must satisfy to dispatch an approval request.
395///
396/// The trait is sync; implementations that need async I/O should use a
397/// dedicated thread or a small runtime. `WebhookChannel` uses the
398/// blocking `ureq` client already in the crate's dependency tree.
399pub trait ApprovalChannel: Send + Sync {
400    /// Short channel name (`"webhook"`, `"slack"`, `"dashboard"`...).
401    fn name(&self) -> &str;
402
403    /// Deliver an approval request to the configured endpoint. The
404    /// channel implementation is responsible for retries; on terminal
405    /// failure the call returns `Err` and the kernel leaves the
406    /// request in the store (fail-closed).
407    fn dispatch(&self, request: &ApprovalRequest) -> Result<ChannelHandle, ChannelError>;
408}
409
410/// Handle returned by `dispatch`. Kernel records this alongside the
411/// request in the store so `cancel` can be called later.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct ChannelHandle {
414    pub channel: String,
415    pub channel_ref: String,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub action_url: Option<String>,
418}
419
420/// Errors returned by approval channels.
421#[derive(Debug, thiserror::Error)]
422pub enum ChannelError {
423    #[error("channel transport error: {0}")]
424    Transport(String),
425    #[error("channel remote rejected dispatch: {status}: {body}")]
426    Remote { status: u16, body: String },
427    #[error("channel misconfigured: {0}")]
428    Config(String),
429}
430
431/// Outcome of running an approval guard against a tool call.
432///
433/// The pending + approved variants box their inner payloads so the
434/// enum stays cheap to pass by value; large variants trip clippy's
435/// `large_enum_variant` lint.
436#[derive(Debug, Clone)]
437pub enum HitlVerdict {
438    /// Guard passes -- no approval required.
439    Allow,
440    /// Guard denies without an approval path (e.g. fail-closed).
441    Deny { reason: String },
442    /// Approval is required. Kernel should persist the request and
443    /// return a 202-style response to the caller.
444    Pending {
445        request: Box<ApprovalRequest>,
446        verdict: Verdict,
447    },
448    /// Approval was supplied with the request and passed verification.
449    Approved { token: Box<ApprovalToken> },
450}
451
452/// Compute the parameter hash that binds an `ApprovalRequest` to a
453/// specific set of arguments. Input is canonicalized via the existing
454/// `sha256_hex(chio_core::canonical::canonical_json_bytes(..))` helper
455/// so independent kernels produce identical hashes.
456#[must_use]
457pub fn compute_parameter_hash(
458    tool_server: &str,
459    tool_name: &str,
460    arguments: &serde_json::Value,
461    governed_intent: Option<&GovernedTransactionIntent>,
462) -> String {
463    let envelope = serde_json::json!({
464        "server_id": tool_server,
465        "tool_name": tool_name,
466        "arguments": arguments,
467        "governed_intent": governed_intent,
468    });
469    match chio_core::canonical::canonical_json_bytes(&envelope) {
470        Ok(bytes) => sha256_hex(&bytes),
471        // Canonicalization only fails on unserializable inputs -- the
472        // tool call arguments are already `serde_json::Value` so this
473        // path is unreachable in practice. Fall back to a tagged hash
474        // of the display form rather than panicking: we still return
475        // a stable string so callers do not have to handle the error.
476        Err(_) => sha256_hex(envelope.to_string().as_bytes()),
477    }
478}
479
480/// The built-in HITL guard. Runs before the generic guard pipeline and
481/// decides whether a call passes straight through, requires approval,
482/// or was already approved by an accompanying token.
483pub struct ApprovalGuard {
484    /// Persistent store of pending / resolved approvals.
485    store: std::sync::Arc<dyn ApprovalStore>,
486    /// Channels fired on new pending requests. Dispatch failures are
487    /// logged but do NOT clear the pending record -- the fail-closed
488    /// rule from the protocol table is that a webhook delivery failure
489    /// keeps the request pending and queryable via the API.
490    channels: Vec<std::sync::Arc<dyn ApprovalChannel>>,
491    /// Default timeout for newly created requests.
492    default_ttl_secs: u64,
493}
494
495impl ApprovalGuard {
496    pub fn new(store: std::sync::Arc<dyn ApprovalStore>) -> Self {
497        Self {
498            store,
499            channels: Vec::new(),
500            default_ttl_secs: 3600,
501        }
502    }
503
504    #[must_use]
505    pub fn with_channel(mut self, channel: std::sync::Arc<dyn ApprovalChannel>) -> Self {
506        self.channels.push(channel);
507        self
508    }
509
510    #[must_use]
511    pub fn with_default_ttl(mut self, secs: u64) -> Self {
512        self.default_ttl_secs = secs;
513        self
514    }
515
516    /// Evaluate the grant's constraints against the request. Returns
517    /// a `HitlVerdict` describing the next step.
518    pub fn evaluate(&self, ctx: ApprovalContext<'_>, now: u64) -> Result<HitlVerdict, KernelError> {
519        let mut triggered = Vec::<String>::new();
520        let mut threshold_hit = false;
521        let mut always_hit = false;
522        let mut tier_hit = false;
523
524        for constraint in ctx.constraints {
525            match constraint {
526                Constraint::RequireApprovalAbove { threshold_units } => {
527                    let amount = ctx
528                        .request
529                        .governed_intent
530                        .as_ref()
531                        .and_then(|intent| intent.max_amount.as_ref());
532                    match amount {
533                        Some(amt) if amt.units >= *threshold_units => {
534                            threshold_hit = true;
535                            triggered.push(format!("require_approval_above:{threshold_units}"));
536                        }
537                        Some(_) => {
538                            // Below threshold -- no approval triggered.
539                        }
540                        None => {
541                            // Fail-closed: constraint present but no
542                            // amount to compare. Deny rather than
543                            // silently skip.
544                            return Ok(HitlVerdict::Deny {
545                                reason: format!(
546                                    "RequireApprovalAbove requires a governed intent with max_amount (threshold={threshold_units})"
547                                ),
548                            });
549                        }
550                    }
551                }
552                Constraint::MinimumAutonomyTier(GovernedAutonomyTier::Autonomous)
553                    if ctx.request.governed_intent.is_some() =>
554                {
555                    // When paired with the HITL guard, Autonomous tier
556                    // is treated as "requires human approval". Direct
557                    // / Delegated pass through.
558                    tier_hit = true;
559                    triggered.push("minimum_autonomy_tier:autonomous".to_string());
560                }
561                _ => {}
562            }
563        }
564
565        // Sentinel for Phase 3.4-3.6: an attribute flag on the request
566        // (`force_approval`) forces a PendingApproval outcome so host
567        // integrations can drop into the HITL flow without teaching
568        // every constraint variant. Test harnesses use this path too.
569        if ctx.force_approval {
570            always_hit = true;
571            triggered.push("force_approval".to_string());
572        }
573
574        let needs_approval = threshold_hit || always_hit || tier_hit;
575        if !needs_approval {
576            return Ok(HitlVerdict::Allow);
577        }
578        if ctx.trusted_approvers.is_empty() {
579            return Ok(HitlVerdict::Deny {
580                reason: "approval required but no trusted approvers are configured".to_string(),
581            });
582        }
583
584        // If the caller attached an approval token, try to satisfy the
585        // request with it before creating a new pending entry.
586        if let Some(token) = ctx.presented_token {
587            // Reconstruct the request envelope matching the original
588            // pending record so we can validate binding.
589            let parameter_hash = compute_parameter_hash(
590                &ctx.request.server_id,
591                &ctx.request.tool_name,
592                &ctx.request.arguments,
593                ctx.request.governed_intent.as_ref(),
594            );
595
596            // Lookup the pending record. If the token refers to an
597            // approval id, prefer the stored record; otherwise build
598            // a synthetic record to verify against (binding by
599            // parameter hash is the cryptographic gate).
600            let stored = self
601                .store
602                .get_pending(&token.approval_id)
603                .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
604            let resolved = self
605                .store
606                .get_resolution(&token.approval_id)
607                .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
608
609            let approval_request = match stored.or_else(|| {
610                resolved.map(|res| ApprovalRequest {
611                    approval_id: res.approval_id,
612                    policy_id: ctx.policy_id.to_string(),
613                    subject_id: ctx.request.agent_id.clone(),
614                    capability_id: ctx.request.capability.id.clone(),
615                    subject_public_key: Some(ctx.request.capability.subject.clone()),
616                    tool_server: ctx.request.server_id.clone(),
617                    tool_name: ctx.request.tool_name.clone(),
618                    action: "invoke".to_string(),
619                    parameter_hash: parameter_hash.clone(),
620                    expires_at: now + self.default_ttl_secs,
621                    callback_hint: None,
622                    created_at: now,
623                    summary: String::new(),
624                    governed_intent: ctx.request.governed_intent.clone(),
625                    trusted_approvers: ctx.trusted_approvers.to_vec(),
626                    triggered_by: triggered.clone(),
627                })
628            }) {
629                Some(record) => record,
630                None => {
631                    return Err(KernelError::ApprovalRejected(
632                        "approval token does not match any known request".into(),
633                    ));
634                }
635            };
636
637            // Replay check first: before spending cycles on signature
638            // verification, fail-closed on a previously consumed token.
639            let already_consumed = self
640                .store
641                .is_consumed(&token.governed_token.id, &approval_request.parameter_hash)
642                .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
643            if already_consumed {
644                return Err(KernelError::ApprovalRejected(
645                    "approval token already consumed (replay)".into(),
646                ));
647            }
648
649            let decision = token.verify_against(&approval_request, now)?;
650            match decision {
651                GovernedApprovalDecision::Approved => Ok(HitlVerdict::Approved {
652                    token: Box::new(token.clone()),
653                }),
654                GovernedApprovalDecision::Denied => Ok(HitlVerdict::Deny {
655                    reason: "human approver denied the request".into(),
656                }),
657            }
658        } else {
659            // No token on the request -- build a new pending entry.
660            let parameter_hash = compute_parameter_hash(
661                &ctx.request.server_id,
662                &ctx.request.tool_name,
663                &ctx.request.arguments,
664                ctx.request.governed_intent.as_ref(),
665            );
666            let expires_at = now.saturating_add(self.default_ttl_secs);
667            let summary = format!(
668                "agent {} requests approval for {}:{}",
669                ctx.request.agent_id, ctx.request.server_id, ctx.request.tool_name
670            );
671            let request = ApprovalRequest {
672                approval_id: ctx
673                    .approval_id_override
674                    .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()),
675                policy_id: ctx.policy_id.to_string(),
676                subject_id: ctx.request.agent_id.clone(),
677                capability_id: ctx.request.capability.id.clone(),
678                subject_public_key: Some(ctx.request.capability.subject.clone()),
679                tool_server: ctx.request.server_id.clone(),
680                tool_name: ctx.request.tool_name.clone(),
681                action: "invoke".to_string(),
682                parameter_hash,
683                expires_at,
684                callback_hint: None,
685                created_at: now,
686                summary,
687                governed_intent: ctx.request.governed_intent.clone(),
688                trusted_approvers: ctx.trusted_approvers.to_vec(),
689                triggered_by: triggered,
690            };
691            self.store
692                .store_pending(&request)
693                .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
694
695            // Dispatch to channels. Delivery failures are logged but
696            // the pending row stays in place: the API can still serve
697            // it from `/approvals/pending`.
698            for channel in &self.channels {
699                if let Err(err) = channel.dispatch(&request) {
700                    tracing::warn!(
701                        approval_id = %request.approval_id,
702                        channel = %channel.name(),
703                        error = %err,
704                        "approval channel dispatch failed; request remains pending"
705                    );
706                }
707            }
708
709            Ok(HitlVerdict::Pending {
710                request: Box::new(request),
711                verdict: Verdict::PendingApproval,
712            })
713        }
714    }
715
716    /// Accessor used by the resume flow in the HTTP layer.
717    #[must_use]
718    pub fn store(&self) -> std::sync::Arc<dyn ApprovalStore> {
719        self.store.clone()
720    }
721}
722
723/// Context passed into [`ApprovalGuard::evaluate`].
724pub struct ApprovalContext<'a> {
725    pub request: &'a ToolCallRequest,
726    pub constraints: &'a [Constraint],
727    pub policy_id: &'a str,
728    /// Public keys trusted to sign the approval token for this request.
729    pub trusted_approvers: &'a [PublicKey],
730    /// Approval token presented by the caller, if any.
731    pub presented_token: Option<&'a ApprovalToken>,
732    /// When `true`, force the guard into the pending path regardless
733    /// of constraints. Used by integration tests and by host adapters
734    /// that decided out-of-band that the call needs approval.
735    pub force_approval: bool,
736    /// Optional deterministic id for the generated approval request.
737    pub approval_id_override: Option<String>,
738}
739
740/// Apply a resolved approval decision: verify the token, mark it
741/// consumed, persist the resolution, and return the final verdict that
742/// the kernel should treat as the outcome.
743pub fn resume_with_decision(
744    store: &dyn ApprovalStore,
745    decision: &ApprovalDecision,
746    now: u64,
747) -> Result<ApprovalOutcome, KernelError> {
748    let pending = match store
749        .get_pending(&decision.approval_id)
750        .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?
751    {
752        Some(p) => p,
753        None => {
754            if let Some(resolution) = store
755                .get_resolution(&decision.approval_id)
756                .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?
757            {
758                return Err(KernelError::ApprovalRejected(format!(
759                    "already resolved: {} ({:?})",
760                    resolution.approval_id, resolution.outcome
761                )));
762            }
763            return Err(KernelError::ApprovalRejected(format!(
764                "unknown approval id: {}",
765                decision.approval_id
766            )));
767        }
768    };
769
770    // Single-use replay check: reject immediately if the token has
771    // already been consumed by a prior resolution.
772    let already = store
773        .is_consumed(&decision.token.id, &pending.parameter_hash)
774        .map_err(|e| KernelError::Internal(format!("approval store: {e}")))?;
775    if already {
776        return Err(KernelError::ApprovalRejected(
777            "approval token already consumed (replay)".into(),
778        ));
779    }
780
781    // Verify the token cryptographically.
782    let approval_token = ApprovalToken {
783        approval_id: pending.approval_id.clone(),
784        governed_token: decision.token.clone(),
785        approver: decision.approver.clone(),
786    };
787    let token_decision = approval_token.verify_against(&pending, now)?;
788
789    // Validate that the HTTP envelope's outcome matches the signed-token
790    // decision BEFORE touching the store. Otherwise a mismatched pair
791    // (token says Denied, body says Approved) would already have flipped
792    // the pending request to `resolved=true` and bumped any approval
793    // counters before we bail out, corrupting approval-threshold state
794    // and replay-protection bookkeeping while still returning an error.
795    let outcome = match (token_decision, &decision.outcome) {
796        (GovernedApprovalDecision::Approved, ApprovalOutcome::Approved) => {
797            ApprovalOutcome::Approved
798        }
799        (GovernedApprovalDecision::Denied, ApprovalOutcome::Denied) => ApprovalOutcome::Denied,
800        _ => {
801            return Err(KernelError::ApprovalRejected(
802                "HTTP outcome disagrees with signed token decision".into(),
803            ));
804        }
805    };
806
807    // Record consumption inside the same store call so it survives a
808    // restart: `resolve` is expected to atomically mark the request
809    // resolved AND record the consumed token id. We only reach this
810    // point once the envelope/token consistency check has passed.
811    store
812        .resolve(&decision.approval_id, decision)
813        .map_err(|e| match e {
814            ApprovalStoreError::AlreadyResolved(m) => {
815                KernelError::ApprovalRejected(format!("already resolved: {m}"))
816            }
817            ApprovalStoreError::Replay(m) => {
818                KernelError::ApprovalRejected(format!("replay detected: {m}"))
819            }
820            other => KernelError::Internal(format!("approval store: {other}")),
821        })?;
822
823    Ok(outcome)
824}
825
826// ---------------------------------------------------------------------
827// In-memory reference implementations.
828// ---------------------------------------------------------------------
829
830/// Thread-safe in-memory `ApprovalStore`. Useful for tests and for
831/// ephemeral deployments where operators explicitly accept data loss
832/// on restart (the opposite of Phase 3.5's durability contract; SQLite
833/// is the production path).
834#[derive(Default)]
835pub struct InMemoryApprovalStore {
836    pending: RwLock<HashMap<String, ApprovalRequest>>,
837    resolved: RwLock<HashMap<String, ResolvedApproval>>,
838    consumed: Mutex<HashMap<String, u64>>, // key: token_id ":" parameter_hash
839    approved_counts: Mutex<HashMap<String, u64>>, // key: subject_id + ":" + policy_id
840}
841
842impl InMemoryApprovalStore {
843    pub fn new() -> Self {
844        Self::default()
845    }
846
847    fn consumed_key(token_id: &str, parameter_hash: &str) -> String {
848        format!("{token_id}:{parameter_hash}")
849    }
850}
851
852impl ApprovalStore for InMemoryApprovalStore {
853    fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError> {
854        let mut guard = self
855            .pending
856            .write()
857            .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
858        match guard.get(&request.approval_id) {
859            Some(existing) if existing == request => return Ok(()),
860            Some(_) => {
861                return Err(ApprovalStoreError::Backend(format!(
862                    "approval_id {} already exists with different payload",
863                    request.approval_id
864                )))
865            }
866            None => {}
867        }
868        guard.insert(request.approval_id.clone(), request.clone());
869        Ok(())
870    }
871
872    fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError> {
873        let guard = self
874            .pending
875            .read()
876            .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
877        Ok(guard.get(id).cloned())
878    }
879
880    fn list_pending(
881        &self,
882        filter: &ApprovalFilter,
883    ) -> Result<Vec<ApprovalRequest>, ApprovalStoreError> {
884        let guard = self
885            .pending
886            .read()
887            .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
888        let mut out: Vec<_> = guard
889            .values()
890            .filter(|req| {
891                filter
892                    .subject_id
893                    .as_deref()
894                    .is_none_or(|s| req.subject_id == s)
895                    && filter
896                        .tool_server
897                        .as_deref()
898                        .is_none_or(|s| req.tool_server == s)
899                    && filter
900                        .tool_name
901                        .as_deref()
902                        .is_none_or(|s| req.tool_name == s)
903                    && filter.not_expired_at.is_none_or(|t| req.expires_at > t)
904            })
905            .cloned()
906            .collect();
907        out.sort_by_key(|approval| approval.created_at);
908        if let Some(limit) = filter.limit {
909            out.truncate(limit);
910        }
911        Ok(out)
912    }
913
914    fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError> {
915        let mut pending_guard = self
916            .pending
917            .write()
918            .map_err(|_| ApprovalStoreError::Backend("pending map poisoned".into()))?;
919        let Some(pending) = pending_guard.remove(id) else {
920            return Err(ApprovalStoreError::NotFound(id.to_string()));
921        };
922
923        {
924            let mut consumed = self
925                .consumed
926                .lock()
927                .map_err(|_| ApprovalStoreError::Backend("consumed map poisoned".into()))?;
928            let key = Self::consumed_key(&decision.token.id, &pending.parameter_hash);
929            if consumed.contains_key(&key) {
930                // Put the pending row back so the caller can retry the
931                // lookup on subsequent requests.
932                pending_guard.insert(id.to_string(), pending);
933                return Err(ApprovalStoreError::Replay(id.to_string()));
934            }
935            consumed.insert(key, decision.received_at);
936        }
937
938        let mut resolved = self
939            .resolved
940            .write()
941            .map_err(|_| ApprovalStoreError::Backend("resolved map poisoned".into()))?;
942        if resolved.contains_key(id) {
943            return Err(ApprovalStoreError::AlreadyResolved(id.to_string()));
944        }
945        resolved.insert(
946            id.to_string(),
947            ResolvedApproval {
948                approval_id: id.to_string(),
949                outcome: decision.outcome.clone(),
950                resolved_at: decision.received_at,
951                approver_hex: decision.approver.to_hex(),
952                token_id: decision.token.id.clone(),
953            },
954        );
955
956        if decision.outcome == ApprovalOutcome::Approved {
957            let mut counts = self
958                .approved_counts
959                .lock()
960                .map_err(|_| ApprovalStoreError::Backend("counts map poisoned".into()))?;
961            let key = format!("{}:{}", pending.subject_id, pending.policy_id);
962            *counts.entry(key).or_default() += 1;
963        }
964
965        Ok(())
966    }
967
968    fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError> {
969        let counts = self
970            .approved_counts
971            .lock()
972            .map_err(|_| ApprovalStoreError::Backend("counts map poisoned".into()))?;
973        Ok(counts
974            .get(&format!("{subject_id}:{policy_id}"))
975            .copied()
976            .unwrap_or(0))
977    }
978
979    fn record_consumed(
980        &self,
981        token_id: &str,
982        parameter_hash: &str,
983        now: u64,
984    ) -> Result<(), ApprovalStoreError> {
985        let mut consumed = self
986            .consumed
987            .lock()
988            .map_err(|_| ApprovalStoreError::Backend("consumed map poisoned".into()))?;
989        let key = Self::consumed_key(token_id, parameter_hash);
990        if consumed.contains_key(&key) {
991            return Err(ApprovalStoreError::Replay(format!(
992                "token {token_id} already consumed"
993            )));
994        }
995        consumed.insert(key, now);
996        Ok(())
997    }
998
999    fn is_consumed(
1000        &self,
1001        token_id: &str,
1002        parameter_hash: &str,
1003    ) -> Result<bool, ApprovalStoreError> {
1004        let consumed = self
1005            .consumed
1006            .lock()
1007            .map_err(|_| ApprovalStoreError::Backend("consumed map poisoned".into()))?;
1008        Ok(consumed.contains_key(&Self::consumed_key(token_id, parameter_hash)))
1009    }
1010
1011    fn get_resolution(&self, id: &str) -> Result<Option<ResolvedApproval>, ApprovalStoreError> {
1012        let guard = self
1013            .resolved
1014            .read()
1015            .map_err(|_| ApprovalStoreError::Backend("resolved map poisoned".into()))?;
1016        Ok(guard.get(id).cloned())
1017    }
1018}
1019
1020/// In-memory `BatchApprovalStore` used in tests. Production backends
1021/// should persist via `SqliteBatchApprovalStore`.
1022#[derive(Default)]
1023pub struct InMemoryBatchApprovalStore {
1024    batches: RwLock<HashMap<String, BatchApproval>>,
1025}
1026
1027impl InMemoryBatchApprovalStore {
1028    pub fn new() -> Self {
1029        Self::default()
1030    }
1031}
1032
1033impl BatchApprovalStore for InMemoryBatchApprovalStore {
1034    fn store(&self, batch: &BatchApproval) -> Result<(), ApprovalStoreError> {
1035        let mut guard = self
1036            .batches
1037            .write()
1038            .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1039        guard.insert(batch.batch_id.clone(), batch.clone());
1040        Ok(())
1041    }
1042
1043    fn find_matching(
1044        &self,
1045        subject_id: &str,
1046        server_id: &str,
1047        tool_name: &str,
1048        amount: Option<&MonetaryAmount>,
1049        now: u64,
1050    ) -> Result<Option<BatchApproval>, ApprovalStoreError> {
1051        let guard = self
1052            .batches
1053            .read()
1054            .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1055        Ok(guard
1056            .values()
1057            .find(|b| {
1058                !b.revoked
1059                    && b.subject_id == subject_id
1060                    && pattern_matches(&b.server_pattern, server_id)
1061                    && pattern_matches(&b.tool_pattern, tool_name)
1062                    && now >= b.not_before
1063                    && now < b.not_after
1064                    && b.max_calls.is_none_or(|c| b.used_calls < c)
1065                    && amount_fits(b, amount)
1066            })
1067            .cloned())
1068    }
1069
1070    fn record_usage(
1071        &self,
1072        batch_id: &str,
1073        amount: Option<&MonetaryAmount>,
1074    ) -> Result<(), ApprovalStoreError> {
1075        let mut guard = self
1076            .batches
1077            .write()
1078            .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1079        let Some(batch) = guard.get_mut(batch_id) else {
1080            return Err(ApprovalStoreError::NotFound(batch_id.to_string()));
1081        };
1082        batch.used_calls = batch.used_calls.saturating_add(1);
1083        if let Some(amt) = amount {
1084            batch.used_total_units = batch.used_total_units.saturating_add(amt.units);
1085        }
1086        Ok(())
1087    }
1088
1089    fn revoke(&self, batch_id: &str) -> Result<(), ApprovalStoreError> {
1090        let mut guard = self
1091            .batches
1092            .write()
1093            .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1094        let Some(batch) = guard.get_mut(batch_id) else {
1095            return Err(ApprovalStoreError::NotFound(batch_id.to_string()));
1096        };
1097        batch.revoked = true;
1098        Ok(())
1099    }
1100
1101    fn get(&self, batch_id: &str) -> Result<Option<BatchApproval>, ApprovalStoreError> {
1102        let guard = self
1103            .batches
1104            .read()
1105            .map_err(|_| ApprovalStoreError::Backend("batch map poisoned".into()))?;
1106        Ok(guard.get(batch_id).cloned())
1107    }
1108}
1109
1110fn pattern_matches(pattern: &str, value: &str) -> bool {
1111    if pattern == "*" {
1112        return true;
1113    }
1114    if let Some(prefix) = pattern.strip_suffix('*') {
1115        return value.starts_with(prefix);
1116    }
1117    pattern == value
1118}
1119
1120fn amount_fits(batch: &BatchApproval, amount: Option<&MonetaryAmount>) -> bool {
1121    let Some(amt) = amount else {
1122        // Calls without a monetary intent match only batches that don't
1123        // constrain per-call amount.
1124        return batch.max_amount_per_call.is_none() && batch.max_total_amount.is_none();
1125    };
1126    if let Some(per_call) = &batch.max_amount_per_call {
1127        if amt.currency != per_call.currency || amt.units > per_call.units {
1128            return false;
1129        }
1130    }
1131    if let Some(total) = &batch.max_total_amount {
1132        if amt.currency != total.currency
1133            || batch.used_total_units.saturating_add(amt.units) > total.units
1134        {
1135            return false;
1136        }
1137    }
1138    true
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::*;
1144    use chio_core::capability::{GovernedApprovalDecision, GovernedApprovalTokenBody};
1145    use chio_core::crypto::Keypair;
1146
1147    fn make_request(approval_id: &str, parameter_hash: &str) -> ApprovalRequest {
1148        let subject = Keypair::generate();
1149        let approver = Keypair::generate();
1150        ApprovalRequest {
1151            approval_id: approval_id.to_string(),
1152            policy_id: "policy-1".into(),
1153            subject_id: "agent-1".into(),
1154            capability_id: "cap-1".into(),
1155            subject_public_key: Some(subject.public_key()),
1156            tool_server: "srv".into(),
1157            tool_name: "invoke".into(),
1158            action: "invoke".into(),
1159            parameter_hash: parameter_hash.to_string(),
1160            expires_at: 1_000_000,
1161            callback_hint: None,
1162            created_at: 0,
1163            summary: String::new(),
1164            governed_intent: None,
1165            trusted_approvers: vec![approver.public_key()],
1166            triggered_by: vec![],
1167        }
1168    }
1169
1170    fn make_token(
1171        approver: &Keypair,
1172        subject: &Keypair,
1173        approval_id: &str,
1174        parameter_hash: &str,
1175        decision: GovernedApprovalDecision,
1176    ) -> GovernedApprovalToken {
1177        let body = GovernedApprovalTokenBody {
1178            id: format!("tok-{approval_id}"),
1179            approver: approver.public_key(),
1180            subject: subject.public_key(),
1181            governed_intent_hash: parameter_hash.to_string(),
1182            request_id: approval_id.to_string(),
1183            issued_at: 10,
1184            expires_at: 100,
1185            decision,
1186        };
1187        GovernedApprovalToken::sign(body, approver).unwrap()
1188    }
1189
1190    #[test]
1191    fn resume_flow_approved() {
1192        let store = InMemoryApprovalStore::new();
1193        let approver = Keypair::generate();
1194        let subject = Keypair::generate();
1195        let mut req = make_request("a-1", "h-1");
1196        req.subject_public_key = Some(subject.public_key());
1197        req.trusted_approvers = vec![approver.public_key()];
1198        store.store_pending(&req).unwrap();
1199
1200        let token = make_token(
1201            &approver,
1202            &subject,
1203            "a-1",
1204            "h-1",
1205            GovernedApprovalDecision::Approved,
1206        );
1207        let decision = ApprovalDecision {
1208            approval_id: "a-1".into(),
1209            outcome: ApprovalOutcome::Approved,
1210            reason: None,
1211            approver: approver.public_key(),
1212            token,
1213            received_at: 50,
1214        };
1215
1216        let outcome = resume_with_decision(&store, &decision, 50).unwrap();
1217        assert_eq!(outcome, ApprovalOutcome::Approved);
1218        assert_eq!(store.count_approved("agent-1", "policy-1").unwrap(), 1);
1219    }
1220
1221    #[test]
1222    fn resume_flow_replay_rejected() {
1223        let store = InMemoryApprovalStore::new();
1224        let approver = Keypair::generate();
1225        let subject = Keypair::generate();
1226        let mut req = make_request("a-2", "h-2");
1227        req.subject_public_key = Some(subject.public_key());
1228        req.trusted_approvers = vec![approver.public_key()];
1229        store.store_pending(&req).unwrap();
1230
1231        let token = make_token(
1232            &approver,
1233            &subject,
1234            "a-2",
1235            "h-2",
1236            GovernedApprovalDecision::Approved,
1237        );
1238        let decision = ApprovalDecision {
1239            approval_id: "a-2".into(),
1240            outcome: ApprovalOutcome::Approved,
1241            reason: None,
1242            approver: approver.public_key(),
1243            token,
1244            received_at: 50,
1245        };
1246
1247        // First resolution succeeds.
1248        resume_with_decision(&store, &decision, 50).unwrap();
1249        // Second resolution must fail (replay).
1250        let err = resume_with_decision(&store, &decision, 51).unwrap_err();
1251        match err {
1252            KernelError::ApprovalRejected(_) => {}
1253            other => panic!("expected ApprovalRejected, got {other:?}"),
1254        }
1255    }
1256
1257    #[test]
1258    fn resume_flow_duplicate_resolution_reports_already_resolved() {
1259        let store = InMemoryApprovalStore::new();
1260        let approver = Keypair::generate();
1261        let subject = Keypair::generate();
1262        let mut req = make_request("a-2b", "h-2b");
1263        req.subject_public_key = Some(subject.public_key());
1264        req.trusted_approvers = vec![approver.public_key()];
1265        store.store_pending(&req).unwrap();
1266
1267        let token = make_token(
1268            &approver,
1269            &subject,
1270            "a-2b",
1271            "h-2b",
1272            GovernedApprovalDecision::Approved,
1273        );
1274        let decision = ApprovalDecision {
1275            approval_id: "a-2b".into(),
1276            outcome: ApprovalOutcome::Approved,
1277            reason: None,
1278            approver: approver.public_key(),
1279            token,
1280            received_at: 50,
1281        };
1282
1283        resume_with_decision(&store, &decision, 50).unwrap();
1284        let err = resume_with_decision(&store, &decision, 51).unwrap_err();
1285        match err {
1286            KernelError::ApprovalRejected(reason) => {
1287                assert!(reason.contains("already resolved"), "{reason}");
1288            }
1289            other => panic!("expected ApprovalRejected, got {other:?}"),
1290        }
1291    }
1292
1293    #[test]
1294    fn verify_against_rejects_wrong_request_id() {
1295        let approver = Keypair::generate();
1296        let subject = Keypair::generate();
1297        let token = make_token(
1298            &approver,
1299            &subject,
1300            "a-X",
1301            "h-X",
1302            GovernedApprovalDecision::Approved,
1303        );
1304        let req = make_request("a-other", "h-X");
1305        let approval_token = ApprovalToken {
1306            approval_id: "a-X".into(),
1307            governed_token: token,
1308            approver: approver.public_key(),
1309        };
1310        let err = approval_token.verify_against(&req, 50).unwrap_err();
1311        match err {
1312            KernelError::ApprovalRejected(_) => {}
1313            other => panic!("expected ApprovalRejected, got {other:?}"),
1314        }
1315    }
1316
1317    #[test]
1318    fn verify_against_rejects_untrusted_approver() {
1319        let trusted_approver = Keypair::generate();
1320        let rogue_approver = Keypair::generate();
1321        let subject = Keypair::generate();
1322        let token = make_token(
1323            &rogue_approver,
1324            &subject,
1325            "a-1",
1326            "h-1",
1327            GovernedApprovalDecision::Approved,
1328        );
1329        let req = ApprovalRequest {
1330            approval_id: "a-1".into(),
1331            policy_id: "policy-1".into(),
1332            subject_id: "agent-1".into(),
1333            capability_id: "cap-1".into(),
1334            subject_public_key: Some(subject.public_key()),
1335            tool_server: "srv".into(),
1336            tool_name: "invoke".into(),
1337            action: "invoke".into(),
1338            parameter_hash: "h-1".into(),
1339            expires_at: 1_000_000,
1340            callback_hint: None,
1341            created_at: 0,
1342            summary: String::new(),
1343            governed_intent: None,
1344            trusted_approvers: vec![trusted_approver.public_key()],
1345            triggered_by: vec![],
1346        };
1347        let approval_token = ApprovalToken {
1348            approval_id: "a-1".into(),
1349            governed_token: token,
1350            approver: rogue_approver.public_key(),
1351        };
1352        let err = approval_token.verify_against(&req, 50).unwrap_err();
1353        match err {
1354            KernelError::ApprovalRejected(reason) => {
1355                assert!(reason.contains("not trusted"), "{reason}");
1356            }
1357            other => panic!("expected ApprovalRejected, got {other:?}"),
1358        }
1359    }
1360
1361    #[test]
1362    fn verify_against_rejects_subject_mismatch() {
1363        let approver = Keypair::generate();
1364        let expected_subject = Keypair::generate();
1365        let rogue_subject = Keypair::generate();
1366        let token = make_token(
1367            &approver,
1368            &rogue_subject,
1369            "a-1",
1370            "h-1",
1371            GovernedApprovalDecision::Approved,
1372        );
1373        let req = ApprovalRequest {
1374            approval_id: "a-1".into(),
1375            policy_id: "policy-1".into(),
1376            subject_id: "agent-1".into(),
1377            capability_id: "cap-1".into(),
1378            subject_public_key: Some(expected_subject.public_key()),
1379            tool_server: "srv".into(),
1380            tool_name: "invoke".into(),
1381            action: "invoke".into(),
1382            parameter_hash: "h-1".into(),
1383            expires_at: 1_000_000,
1384            callback_hint: None,
1385            created_at: 0,
1386            summary: String::new(),
1387            governed_intent: None,
1388            trusted_approvers: vec![approver.public_key()],
1389            triggered_by: vec![],
1390        };
1391        let approval_token = ApprovalToken {
1392            approval_id: "a-1".into(),
1393            governed_token: token,
1394            approver: approver.public_key(),
1395        };
1396        let err = approval_token.verify_against(&req, 50).unwrap_err();
1397        match err {
1398            KernelError::ApprovalRejected(reason) => {
1399                assert!(reason.contains("subject"), "{reason}");
1400            }
1401            other => panic!("expected ApprovalRejected, got {other:?}"),
1402        }
1403    }
1404}