apexe 0.7.0

Outside-In CLI-to-Agent Bridge
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
//! The approval gate `--enable-approval` installs.
//!
//! apcore-mcp 0.18 made a real human-in-the-loop prompt reachable from a
//! handler built outside its router, which is the shape apexe has: the CLI
//! entry point constructs the `Executor` long before any session exists. The
//! router now registers the connection's `ElicitCallback` per tool call and
//! writes its id into `Context.data` under `MCP_ELICIT_CALL_ID_KEY`, and
//! [`ElicitationApprovalHandler`] exchanges that id for the live callback. An
//! id is a `serde_json::Value` where a closure is not, which is what the
//! earlier design could not get past — apexe's `Context` could learn that
//! elicitation existed (`MCP_ELICIT_KEY` holds the string `"available"`) and
//! never perform one.
//!
//! So this wraps the upstream handler rather than replacing it, for two things
//! upstream cannot do from where it sits:
//!
//! * **Audit.** apcore runs `approval_gate` *before* `middleware_before`, so a
//!   denial here never reaches
//!   [`FailureLogMiddleware`](crate::module::FailureLogMiddleware). Nothing
//!   else in the stack observes it either — unlike an ACL denial, which apcore
//!   records itself. Without this the refusal a governed deployment produces
//!   most often would leave no trace.
//! * **A reason an operator can act on.** When the connected client declared no
//!   elicitation support there is no prompt to deliver, and upstream says so as
//!   `"Elicitation returned no response"`. That names the mechanism, not the
//!   remedy. An operator who turns the gate on and reads that on every
//!   destructive tool concludes the flag is broken and turns it back off,
//!   landing on the *ungoverned* default — strictly worse than either
//!   alternative. A human's own "no" is left verbatim; it is already clear.

use std::sync::Arc;

use apcore::approval::{ApprovalHandler, ApprovalRequest, ApprovalResult};
use apcore::{ErrorCode, ModuleError};
use apcore_mcp::ElicitationApprovalHandler;
use async_trait::async_trait;

use crate::adapter::annotations::{
    APPROVAL_BASIS_FLAGS, APPROVAL_BASIS_KEY, ESCALATING_PARAMS_KEY,
};
use crate::module::executor::is_effective;

/// The reasons upstream gives when no prompt could be delivered at all.
///
/// Distinct from a human declining, which needs no rewriting. Matching on
/// upstream's literals is deliberate but not load-bearing: if they change, the
/// gate falls through to reporting upstream's own reason, which is exactly
/// today's behaviour rather than something wrong.
/// `test_no_elicitation_path_is_reported_with_a_remedy` drives the real
/// handler, so drift fails the suite rather than passing silently.
const NO_PROMPT_REASONS: [&str; 2] = [
    "No context available for elicitation",
    "No elicitation callback available",
];

/// Upstream's reason when a prompt was sent and no answer came back.
///
/// Kept apart from [`NO_PROMPT_REASONS`] because it does **not** mean the client
/// declared no elicitation support: apcore-mcp returns it whenever the router's
/// callback yields `None`, which covers a transport failure, the client
/// disconnecting mid-prompt, and an unparseable answer. Telling an
/// elicitation-capable operator that their client lacks the capability sends
/// them to the wrong remedy — the same give-up-and-revert failure this module
/// exists to prevent.
const NO_ANSWER_REASON: &str = "Elicitation returned no response";

/// `module_id` recorded for a [`ApprovalGate::check_approval`] refusal.
///
/// `check_approval` receives only a caller-supplied, unvalidated token —
/// apcore dispatches to it straight from `tools/call` arguments, ahead of
/// `input_validation` — so that token must never become the audit record's
/// `module_id`: doing so would let any caller write an arbitrary string into
/// the field a governance record's identity depends on. The token itself is
/// still recorded, in `ExecutionRecord::approval_id`.
const APPROVAL_TOKEN_LOOKUP_MODULE_ID: &str = "<approval-token-lookup>";

/// What the gate does with one outcome from the upstream handler.
#[derive(Debug, PartialEq, Eq)]
enum Disposition {
    /// A human approved. Pass it through untouched and audit nothing: the
    /// execution it permits is recorded on its own under the same `trace_id`.
    Granted,
    /// The decision is still out — an `ApprovalStore` recorded the request and
    /// someone will resolve it later. Not a refusal, so nothing is audited
    /// here; apcore raises `ApprovalPending` and the caller retries.
    Pending,
    /// No prompt could be delivered at all. Replace the reason with one naming
    /// the remedy, and audit the refusal.
    NoPromptAvailable,
    /// A prompt went out and nothing came back. Say so without asserting a
    /// cause, and audit the refusal.
    NoAnswer,
    /// A human refused, or something else went wrong. Their reason already says
    /// what happened; audit the refusal and leave it alone.
    Refused,
}

/// Decide what to do with `result`.
///
/// Split out from [`ApprovalGate::request_approval`] so the `Granted` arm is
/// reachable from a test. Driving the real handler can only ever produce a
/// rejection — a bare request carries no elicitation callback — so an
/// approval that was mistakenly audited as a refusal, which would put
/// `APPROVAL_DENIED` in the trail for a call that actually ran, was
/// undetectable while this was an `if` inside the trait method.
fn classify(result: &ApprovalResult) -> Disposition {
    match result.status.as_str() {
        "approved" => return Disposition::Granted,
        "pending" => return Disposition::Pending,
        _ => {}
    }
    match result.reason.as_deref() {
        Some(reason) if NO_PROMPT_REASONS.contains(&reason) => Disposition::NoPromptAvailable,
        Some(NO_ANSWER_REASON) => Disposition::NoAnswer,
        _ => Disposition::Refused,
    }
}

/// Whether this request has to be put to a human at all.
///
/// A module marked `requires_approval` only because it *accepts* an escalating
/// flag (see [`APPROVAL_FLAGS`]) needs no prompt for a call that sent none of
/// them. apcore cannot make this call and neither can anything upstream of
/// here: the gate runs or does not run according to a static annotation, and
/// neither `ApprovalHandler` nor the `ExecutionPolicy` hook that can override
/// it is passed a call's arguments. [`ApprovalRequest`] carries them, so this
/// is the first and only place the question is answerable.
///
/// Every branch that cannot read the basis returns `true`. The two annotation
/// keys are written together by `mark_escalating_params`, so a `flags` basis
/// with no list — or with arguments that are not an object — is a corrupted
/// annotation rather than an empty one, and standing down on it would turn
/// corruption into a silent bypass of the gate.
///
/// [`APPROVAL_FLAGS`]: crate::adapter::annotations
fn needs_prompt(request: &ApprovalRequest) -> bool {
    let extra = &request.annotations.extra;
    let flag_derived = extra
        .get(APPROVAL_BASIS_KEY)
        .and_then(|basis| basis.as_str())
        == Some(APPROVAL_BASIS_FLAGS);
    if !flag_derived {
        return true;
    }
    let Some(escalating) = extra
        .get(ESCALATING_PARAMS_KEY)
        .and_then(|list| list.as_array())
    else {
        return true;
    };
    let Some(arguments) = request.arguments.as_object() else {
        return true;
    };
    escalating
        .iter()
        .filter_map(|name| name.as_str())
        .any(|name| arguments.get(name).is_some_and(is_effective))
}

/// The `approved_by` recorded when the gate stands down on its own.
///
/// Names apexe rather than a person, so an audit reader can tell a call that
/// nobody was asked about from one a human waved through.
const STOOD_DOWN_AUTHORITY: &str = "apexe-approval-gate";

/// The `reason` recorded when the gate stands down on its own.
const STOOD_DOWN_REASON: &str =
    "Approved without prompting: this module is gated only because it accepts an escalating \
     flag, and this call sent none of them.";

/// What a caller is told when a prompt went out and no answer came back.
fn no_answer_reason(module_id: &str) -> String {
    format!(
        "Module '{module_id}' is marked `requires_approval` and no answer to the approval prompt \
         came back. The client may not support MCP elicitation, or the connection may have \
         dropped before the prompt was answered — this is not a human declining. Retry, connect a \
         client that supports elicitation, use `--acl` to grant specific callers access to \
         specific modules, or embed apexe as a library with an `ApprovalStore` for out-of-band \
         approvals."
    )
}

/// What a caller is told when the gate is on and no prompt can be delivered.
fn no_prompt_reason(module_id: &str) -> String {
    format!(
        "Module '{module_id}' is marked `requires_approval` and this connection cannot be \
         prompted: the client declared no MCP elicitation support when it initialized, so there \
         is nobody to ask. This is a refusal for want of a prompt, not a human declining one. \
         Connect a client that supports elicitation, use `--acl` to grant specific callers access \
         to specific modules, or embed apexe as a library with an `ApprovalStore` for \
         out-of-band approvals."
    )
}

/// Prompt for approval through the connected MCP client, and record the answer.
///
/// Installed by [`build_executor`](crate::module::build_executor) when
/// `enable_approval` is set and no [`ApprovalStore`](apcore_mcp::ApprovalStore)
/// was supplied. See the module docs for what this adds over
/// [`ElicitationApprovalHandler`] alone.
pub struct ApprovalGate {
    /// The handler that actually decides. Boxed so the same audit-and-reword
    /// wrapper serves both handlers `build_executor` can install: the
    /// elicitation gate, and the `ApprovalStore`-backed one whose refusals
    /// reach no middleware either.
    inner: Box<dyn ApprovalHandler>,
    /// Governance audit sink. `None` disables auditing.
    ///
    /// Only refusals are written. A *granted* approval is followed by the
    /// execution it permitted, which `CliModule` records under the same
    /// `trace_id`, so the grant is already in the trail; a refusal is the
    /// outcome that would otherwise leave none.
    audit: Option<Arc<crate::governance::AuditManager>>,
}

impl ApprovalGate {
    /// Create the gate with no audit sink.
    pub fn new() -> Self {
        Self::with_audit(None)
    }

    /// Create the gate, recording each refusal to `audit`.
    pub fn with_audit(audit: Option<Arc<crate::governance::AuditManager>>) -> Self {
        // `None`: the callback is per-request and comes from the live router,
        // which is the whole point — see the module docs.
        Self::wrapping(Box::new(ElicitationApprovalHandler::new(None)), audit)
    }

    /// Wrap an arbitrary approval handler, auditing every refusal it returns.
    ///
    /// The audit guarantee is a property of *where the gate runs*, not of which
    /// handler decides: `approval_gate` precedes `middleware_before`, so no
    /// middleware observes any refusal from it. A store-backed deployment — the
    /// one the manual documents as the production answer — was getting the
    /// worse of the two guarantees while the audit sink sat unused one line
    /// away in `install_approval_handler`.
    pub fn wrapping(
        inner: Box<dyn ApprovalHandler>,
        audit: Option<Arc<crate::governance::AuditManager>>,
    ) -> Self {
        Self { inner, audit }
    }

    /// Record one approval-gate refusal.
    ///
    /// `duration_ms` is 0: the gate runs before anything is timed, and inventing
    /// an elapsed time for a call that never started would be worse than
    /// reporting none. `approval_id` is `Some` only from
    /// [`check_approval`](Self::check_approval)'s refusal path — see that
    /// method's doc comment and [`APPROVAL_TOKEN_LOOKUP_MODULE_ID`].
    async fn audit_refusal(
        &self,
        module_id: &str,
        trace_id: &str,
        caller_id: Option<&str>,
        approval_id: Option<&str>,
    ) {
        let Some(ref audit) = self.audit else {
            return;
        };
        audit
            .log_refusal(
                module_id,
                trace_id,
                caller_id,
                approval_id,
                ErrorCode::ApprovalDenied,
                0,
            )
            .await;
    }

    /// Record a refusal for a request that carries its own context.
    async fn audit_request_refusal(&self, request: &ApprovalRequest) {
        let context = request.context.as_ref();
        self.audit_refusal(
            &request.module_id,
            context.map_or("", |ctx| ctx.trace_id.as_str()),
            context
                .and_then(|ctx| ctx.identity.as_ref())
                .map(|id| id.id()),
            None,
        )
        .await;
    }
}

impl std::fmt::Debug for ApprovalGate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ApprovalGate")
            .field("has_audit", &self.audit.is_some())
            .finish()
    }
}

impl Default for ApprovalGate {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl ApprovalHandler for ApprovalGate {
    async fn request_approval(
        &self,
        request: &ApprovalRequest,
    ) -> Result<ApprovalResult, ModuleError> {
        if !needs_prompt(request) {
            tracing::debug!(
                module_id = %request.module_id,
                "Approval gate stood down: this call carries no escalating argument"
            );
            // ApprovalResult is #[non_exhaustive]; build via Default and assign
            // fields rather than a struct literal.
            let mut result = ApprovalResult::default();
            result.status = "approved".to_string();
            result.approved_by = Some(STOOD_DOWN_AUTHORITY.to_string());
            result.reason = Some(STOOD_DOWN_REASON.to_string());
            return Ok(result);
        }
        let mut result = self.inner.request_approval(request).await?;
        match classify(&result) {
            Disposition::Granted => {
                tracing::info!(
                    module_id = %request.module_id,
                    "Approval granted"
                );
                return Ok(result);
            }
            Disposition::Pending => {
                tracing::info!(
                    module_id = %request.module_id,
                    "Approval recorded as pending for out-of-band resolution"
                );
                return Ok(result);
            }
            Disposition::NoPromptAvailable => {
                tracing::warn!(
                    module_id = %request.module_id,
                    "Denying approval-gated call: this client declared no elicitation support"
                );
                result.reason = Some(no_prompt_reason(&request.module_id));
            }
            Disposition::NoAnswer => {
                tracing::warn!(
                    module_id = %request.module_id,
                    "Denying approval-gated call: the prompt went out and no answer came back"
                );
                result.reason = Some(no_answer_reason(&request.module_id));
            }
            Disposition::Refused => {
                tracing::info!(
                    module_id = %request.module_id,
                    reason = ?result.reason,
                    "Approval refused"
                );
            }
        }
        self.audit_request_refusal(request).await;
        Ok(result)
    }

    /// Resolve a previously-issued approval id.
    ///
    /// **apcore reaches this from ordinary caller input.** When a `tools/call`'s
    /// arguments carry an `_approval_token` property, the approval gate calls
    /// this instead of [`request_approval`](Self::request_approval) — and it
    /// does so before `input_validation`, so nothing has rejected the extra
    /// property yet. Nothing is ever actually pending here (this gate resolves
    /// synchronously against the live connection), so the answer is always a
    /// refusal.
    ///
    /// That refusal has to be audited like any other. It reaches no middleware —
    /// `approval_gate` runs before `middleware_before`, so
    /// [`FailureLogMiddleware`](crate::module::FailureLogMiddleware) never sees
    /// it — so without this a caller suppressed the record of their own denied
    /// attempt by adding one property to the arguments object. Tool arguments
    /// arriving over MCP are external input.
    ///
    /// The record carries no trace or identity: an `ApprovalHandler` receives
    /// only the id on this path. That id is caller-supplied and unvalidated —
    /// `input_validation` has not run yet (see above) — so it is recorded in
    /// `approval_id`, never in `module_id`: writing unvalidated input into the
    /// field a governance record's identity depends on would let a caller
    /// frame an arbitrary module_id as having been denied.
    /// [`APPROVAL_TOKEN_LOOKUP_MODULE_ID`] is what `module_id` carries
    /// instead, on every record this method produces.
    async fn check_approval(&self, approval_id: &str) -> Result<ApprovalResult, ModuleError> {
        let result = self.inner.check_approval(approval_id).await?;
        if matches!(
            classify(&result),
            Disposition::Granted | Disposition::Pending
        ) {
            return Ok(result);
        }
        tracing::warn!(
            approval_id,
            reason = ?result.reason,
            "Refusing an approval-token lookup: this gate holds no pending approvals"
        );
        self.audit_refusal(APPROVAL_TOKEN_LOOKUP_MODULE_ID, "", None, Some(approval_id))
            .await;
        Ok(result)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn request(module_id: &str) -> ApprovalRequest {
        let mut request = ApprovalRequest::default();
        request.module_id = module_id.to_string();
        request
    }

    /// A request for a module marked only because it accepts `escalating`,
    /// called with `arguments` — the shape `mark_escalating_params` produces.
    fn flag_derived_request(escalating: &[&str], arguments: serde_json::Value) -> ApprovalRequest {
        let mut request = request("cli.git.push");
        request.arguments = arguments;
        request.annotations.requires_approval = true;
        request.annotations.extra.insert(
            APPROVAL_BASIS_KEY.to_string(),
            serde_json::json!(APPROVAL_BASIS_FLAGS),
        );
        request.annotations.extra.insert(
            ESCALATING_PARAMS_KEY.to_string(),
            serde_json::json!(escalating),
        );
        request
    }

    /// The fatigue case: `git log` accepts `--all`, so the module is marked,
    /// but this call sent nothing escalating and nobody should be asked.
    #[tokio::test]
    async fn test_the_gate_stands_down_when_no_escalating_argument_is_sent() {
        let request = flag_derived_request(&["all", "force"], serde_json::json!({"oneline": true}));
        assert!(!needs_prompt(&request));

        let gate = ApprovalGate::wrapping(Box::new(RefusingHandler), None);
        let result = gate.request_approval(&request).await.unwrap();
        assert_eq!(result.status, "approved");
        assert_eq!(result.approved_by.as_deref(), Some(STOOD_DOWN_AUTHORITY));
    }

    #[tokio::test]
    async fn test_the_gate_prompts_when_an_escalating_argument_is_sent() {
        let request = flag_derived_request(&["all", "force"], serde_json::json!({"force": true}));
        assert!(needs_prompt(&request));
    }

    /// `{"force": false}` renders no flag at all, so it is not "sending" it —
    /// the same rule `executor::is_effective` applies when building argv.
    #[tokio::test]
    async fn test_an_ineffective_escalating_argument_does_not_prompt() {
        for ineffective in [serde_json::json!(false), serde_json::json!(null)] {
            let request =
                flag_derived_request(&["force"], serde_json::json!({"force": ineffective}));
            assert!(
                !needs_prompt(&request),
                "{ineffective} renders nothing, so no escalating flag reaches argv"
            );
        }
    }

    /// An unconditional mark — destructive by name, or asserted by an overlay —
    /// carries no basis, and must never be downgraded to a conditional one.
    #[tokio::test]
    async fn test_a_mark_without_a_flag_basis_always_prompts() {
        let mut request = request("cli.rm");
        request.arguments = serde_json::json!({});
        request.annotations.requires_approval = true;
        assert!(needs_prompt(&request));
    }

    /// The two keys are written together. A `flags` basis missing its list is a
    /// corrupted annotation, not an empty one; standing down on it would turn
    /// corruption into a silent bypass of the gate.
    #[tokio::test]
    async fn test_a_flag_basis_without_a_parameter_list_prompts() {
        let mut request = request("cli.git.push");
        request.arguments = serde_json::json!({});
        request.annotations.requires_approval = true;
        request.annotations.extra.insert(
            APPROVAL_BASIS_KEY.to_string(),
            serde_json::json!(APPROVAL_BASIS_FLAGS),
        );
        assert!(needs_prompt(&request));
    }

    #[tokio::test]
    async fn test_non_object_arguments_prompt_rather_than_stand_down() {
        let request = flag_derived_request(&["force"], serde_json::json!("not-an-object"));
        assert!(needs_prompt(&request));
    }

    /// An approval handler that always refuses, so a stand-down is provably
    /// the gate's own decision rather than the inner handler's.
    #[derive(Debug)]
    struct RefusingHandler;

    #[async_trait]
    impl ApprovalHandler for RefusingHandler {
        async fn request_approval(
            &self,
            _request: &ApprovalRequest,
        ) -> Result<ApprovalResult, ModuleError> {
            Ok(result_with("rejected", Some("User action: decline")))
        }

        async fn check_approval(&self, _approval_id: &str) -> Result<ApprovalResult, ModuleError> {
            Ok(result_with("rejected", Some("User action: decline")))
        }
    }

    fn result_with(status: &str, reason: Option<&str>) -> ApprovalResult {
        let mut result = ApprovalResult::default();
        result.status = status.to_string();
        result.reason = reason.map(str::to_string);
        result
    }

    #[tokio::test]
    async fn test_a_granted_approval_is_never_recorded_as_a_refusal() {
        // The failure this guards is a falsified audit trail: an approved call
        // audited as a refusal writes APPROVAL_DENIED for a call that actually
        // ran, so the trail reports the opposite of what happened.
        assert_eq!(
            classify(&result_with("approved", None)),
            Disposition::Granted
        );
    }

    #[tokio::test]
    async fn test_a_humans_refusal_keeps_its_own_reason() {
        // "User action: decline" already says what happened. Rewriting it would
        // replace a true statement with a generic one.
        assert_eq!(
            classify(&result_with("rejected", Some("User action: decline"))),
            Disposition::Refused
        );
    }

    #[tokio::test]
    async fn test_an_undeliverable_prompt_is_distinguished_from_a_refusal() {
        for reason in NO_PROMPT_REASONS {
            assert_eq!(
                classify(&result_with("rejected", Some(reason))),
                Disposition::NoPromptAvailable,
                "{reason} means no prompt reached anyone"
            );
        }
    }

    #[tokio::test]
    async fn test_an_unanswered_prompt_does_not_claim_the_client_lacks_the_capability() {
        // apcore-mcp returns this whenever its callback yields `None`, which
        // covers a transport failure and a mid-prompt disconnect as well as a
        // client with no elicitation support. Folding it in with the other two
        // told an elicitation-capable operator the opposite of what their own
        // `initialize` handshake said, and sent them to the wrong remedy.
        assert_eq!(
            classify(&result_with("rejected", Some(NO_ANSWER_REASON))),
            Disposition::NoAnswer
        );
        let unanswered = no_answer_reason("cli.rm");
        let unpromptable = no_prompt_reason("cli.rm");
        assert_ne!(
            unanswered, unpromptable,
            "the two outcomes must not read as the same diagnosis"
        );
        assert!(
            !unanswered.contains("declared no MCP elicitation support"),
            "an unanswered prompt must not assert a cause it did not observe: {unanswered}"
        );
        assert!(
            unanswered.contains("--acl"),
            "it must still name the alternative: {unanswered}"
        );
    }

    #[tokio::test]
    async fn test_no_elicitation_path_is_reported_with_a_remedy() {
        // A bare `ApprovalRequest` carries no context and therefore no live
        // callback id, which is what a client declaring no elicitation support
        // produces. Upstream answers "No context available for elicitation";
        // that names the mechanism and no way out of it.
        //
        // This drives the real upstream handler, so it is also the guard on
        // `NO_PROMPT_REASONS`: if apcore-mcp rewords its refusal, this fails
        // rather than silently passing the raw string through.
        let result = ApprovalGate::new()
            .request_approval(&request("cli.rm"))
            .await
            .expect("the gate answers rather than erroring");

        assert_eq!(result.status, "rejected");
        let reason = result.reason.expect("a reason must be attached");
        // The three things an operator needs: which module, why no prompt
        // arrived, and what to reach for instead.
        assert!(
            reason.contains("cli.rm"),
            "reason names the module: {reason}"
        );
        assert!(
            reason.contains("no MCP elicitation support"),
            "reason says why no prompt arrived: {reason}"
        );
        assert!(
            reason.contains("--acl"),
            "reason names the alternative: {reason}"
        );
    }

    #[tokio::test]
    async fn test_refusal_reaches_the_audit_trail() {
        // The approval gate runs ahead of the middleware phase, so a refusal is
        // invisible to `FailureLogMiddleware` and apcore emits no audit entry
        // of its own for it — unlike an ACL denial.
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("audit.jsonl");
        let gate =
            ApprovalGate::with_audit(Some(Arc::new(crate::governance::AuditManager::new(&path))));

        gate.request_approval(&request("cli.rm"))
            .await
            .expect("the gate answers rather than erroring");

        let content = std::fs::read_to_string(&path).expect("a refusal must be recorded");
        let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
        assert_eq!(entry["event"], "refusal");
        assert_eq!(entry["module_id"], "cli.rm");
        assert_eq!(entry["error_code"], "APPROVAL_DENIED");
    }

    #[tokio::test]
    async fn test_an_approval_token_lookup_is_audited_like_any_other_refusal() {
        // apcore calls `check_approval` instead of `request_approval` whenever
        // the arguments carry `_approval_token`, and it does so before
        // `input_validation` — so nothing has rejected the extra property yet.
        // The refusal reaches no middleware either (`approval_gate` precedes
        // `middleware_before`), so before this a caller suppressed the record of
        // their own denied attempt by adding one property to the object.
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("audit.jsonl");
        let gate =
            ApprovalGate::with_audit(Some(Arc::new(crate::governance::AuditManager::new(&path))));

        let result = gate
            .check_approval("caller-supplied-token")
            .await
            .expect("the gate answers rather than erroring");
        assert_eq!(result.status, "rejected");

        let content = std::fs::read_to_string(&path).expect("a refusal must be recorded");
        let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
        assert_eq!(entry["event"], "refusal");
        assert_eq!(entry["error_code"], "APPROVAL_DENIED");
        // Regression: the caller-supplied token used to be written straight
        // into `module_id` -- apcore reads `_approval_token` from `tools/call`
        // arguments and dispatches here *before* `input_validation`, so a
        // caller could write an arbitrary string into the governance trail's
        // `module_id` field (e.g. framing another module as having been
        // denied). `module_id` must stay a fixed sentinel; the caller's token
        // is recorded separately, where it cannot be mistaken for one.
        assert_eq!(
            entry["module_id"], "<approval-token-lookup>",
            "a caller-supplied token must never become the audit record's module_id: {entry}"
        );
        assert_eq!(
            entry["approval_id"], "caller-supplied-token",
            "the token must still be recorded, just not as module_id: {entry}"
        );
        assert!(
            entry.get("trace_id").is_none(),
            "no context reaches this path, so the join key is omitted rather than \
             claimed blank: {entry}"
        );
    }

    #[tokio::test]
    async fn test_refusal_without_an_audit_sink_writes_nothing() {
        // `--audit` off must stay off; the gate still refuses.
        let result = ApprovalGate::new()
            .request_approval(&request("cli.rm"))
            .await
            .expect("the gate answers rather than erroring");
        assert_eq!(result.status, "rejected");
    }

    #[tokio::test]
    async fn test_check_approval_reports_nothing_pending() {
        let result = ApprovalGate::new()
            .check_approval("any-id")
            .await
            .expect("the gate answers rather than erroring");
        assert_eq!(result.status, "rejected");
    }
}