Skip to main content

wm_dispatch/
pipeline.rs

1//! Dispatch pipeline — the request processing chain.
2//!
3//! Pipeline order:
4//! 1. Effect check — brain-wave compatibility (zero-cost, inline)
5//! 2. Dharma gate — ethical governance verdict
6//! 3. Resource rules — write/spawn/network budgets, novelty, human review
7//! 4. Rate limit — sliding window per-tool + global
8//! 5. Circuit breaker — fault tolerance, fast-fail on repeated errors
9//! 6. Tool call — execute the tool (optionally bounded by a dispatch timeout).
10//!    Secret-scan sampling (6b) runs right after a successful call: warn-only
11//!    credential-shape scan, deterministic 1-in-N, content never logged
12//!    (P-PROV-5/B(c)).
13//! 7. Karma record + write-audit journal — declared vs actual effects
14//!    (confirm-gated dispatches record the confirm — the delete-confirm audit)
15//! 8. Stats — success/failure and latency tracking
16//!
17//! Between 4 and 5 sits the firebreak (fix-queue P1.4+P1.6): the explicit
18//! `confirm: true` gate for destructive tools, the promoted Jan-11
19//! forbidden-command veto, the bulk-scope law, and advisory disclosure.
20
21#[cfg(test)]
22use async_trait::async_trait;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use wm_core::{Args, Context, CoreError, Output, Result, Tool};
26
27use crate::capability_gate::{CapabilityGateMode, GateOutcome};
28use crate::circuit_breaker::CircuitBreakerRegistry;
29use crate::rate_limiter::RateLimiter;
30use wm_governance::{
31    ActionVerdict, DharmaGate, FirebreakOutcome, KarmaLedger, ResourceRules, ResourceVerdict,
32};
33
34/// Default dispatch timeout (300s) applied by [`DispatchPipeline::from_env`]
35/// when `WM_DISPATCH_TIMEOUT_MS` is unset.
36///
37/// Generous enough for LLM-backed tools (research, self-play) while still
38/// bounding a hung call.
39pub const DEFAULT_DISPATCH_TIMEOUT: Duration = Duration::from_secs(300);
40
41/// Stable 64-bit hash of the serialized args — drives novelty tracking so
42/// identical repeated calls are recognizable across dispatches.
43fn hash_args(args: &Args) -> u64 {
44    use std::hash::Hasher;
45    let bytes = serde_json::to_vec(args).unwrap_or_default();
46    let mut hasher = ahash::AHasher::default();
47    hasher.write(&bytes);
48    hasher.finish()
49}
50
51/// First non-empty string found under any of the given keys.
52fn first_str(v: &serde_json::Value, keys: &[&str]) -> Option<String> {
53    keys.iter().find_map(|k| {
54        v.get(*k)
55            .and_then(serde_json::Value::as_str)
56            .map(str::to_string)
57    })
58}
59
60/// Append a write-audit journal entry for one dispatch.
61///
62/// `store_write_baseline` must be sampled at dispatch start (see
63/// [`WriteAuditJournal::dispatch_baseline`]) so the entry attributes exactly
64/// the mutations that happened while this dispatch ran — not whatever other
65/// dispatches (or bookkeeping flushes) wrote since the previous entry.
66/// `confirm_gated` is `Some(confirm)` for destructive dispatches — the
67/// delete-confirm audit field (P1.6) — and `None` for everything else.
68#[allow(clippy::too_many_arguments)]
69fn record_write_audit(
70    journal: &wm_governance::WriteAuditJournal,
71    store_write_baseline: u64,
72    tool: &str,
73    actor: wm_governance::ActorIdentity,
74    declared_writes: bool,
75    args_memory_id: Option<&str>,
76    args_content_hash: Option<&str>,
77    args_digest: Option<String>,
78    output: &serde_json::Value,
79    success: bool,
80    confirm_gated: Option<bool>,
81) {
82    // The meta-router (`wm`) mutates only through nested dispatches, which
83    // journal themselves with the real tool identity; a router entry would
84    // attribute the inner writes to 'wm' as an undeclared mutation — a
85    // permanent false misdeclaration for every meta-routed write (first-run
86    // feedback, 2026-09-13: `wm doctor` never reached a clean summary).
87    if tool == "wm" {
88        return;
89    }
90    let reported_writes = output
91        .get("writes")
92        .and_then(|w| w.as_array())
93        .map_or(0, |a| a.len() as u32);
94    let memory_id = first_str(output, &["id", "memory_id", "memory"])
95        .or_else(|| args_memory_id.map(str::to_string));
96    let content_hash = first_str(output, &["content_hash", "hash", "sha256"])
97        .or_else(|| args_content_hash.map(str::to_string));
98    let result = match confirm_gated {
99        Some(confirmed) => journal.record_since_confirmed(
100            store_write_baseline,
101            tool,
102            actor,
103            memory_id.as_deref(),
104            content_hash.as_deref(),
105            declared_writes,
106            reported_writes,
107            success,
108            confirmed,
109            args_digest,
110        ),
111        None => journal.record_since(
112            store_write_baseline,
113            tool,
114            actor,
115            memory_id.as_deref(),
116            content_hash.as_deref(),
117            declared_writes,
118            reported_writes,
119            success,
120            args_digest,
121        ),
122    };
123    if let Err(e) = result {
124        tracing::warn!(error = %e, "Write-audit journal record failed");
125    }
126}
127
128/// Verified gate-lite pass evidence (S2) — plain data, no verifier dependency.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct PassQuotas {
131    /// CPU budget (milliseconds).
132    pub cpu_ms: u64,
133    /// Memory cap (MiB).
134    pub mem_mb: u64,
135    /// Disk cap (MiB).
136    pub disk_mb: u64,
137    /// Wall-clock budget (milliseconds).
138    pub wall_ms: u64,
139}
140
141/// Optional spend budget carried by a pass.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct PassBudget {
144    /// Amount in minor units.
145    pub minor: u64,
146    /// ISO-4217-like currency code.
147    pub currency: String,
148}
149
150/// Verified gate-lite pass evidence handed to the authority-seam hook.
151#[derive(Debug, Clone)]
152pub struct PassEvidence {
153    /// `iss` (`gate:<gate_id>`).
154    pub issuer: String,
155    /// `sub` (agent `did:key`).
156    pub subject: String,
157    /// `aud`.
158    pub audience: String,
159    /// `mandala.class`.
160    pub gate_class: String,
161    /// `mandala.slot_class`.
162    pub slot_class: String,
163    /// The gate's `did:key`.
164    pub gate_did: String,
165    /// `policy_version`.
166    pub policy_version: String,
167    /// `exp` (epoch seconds).
168    pub expires_at: i64,
169    /// `mandala.quotas`.
170    pub quotas: PassQuotas,
171    /// Budget when present.
172    pub budget: Option<PassBudget>,
173    /// `jti`.
174    pub jti: String,
175    /// `sha256:` of the raw token — the pass commitment.
176    pub token_digest: String,
177}
178
179/// Offline pass verifier injected by the deployment (wm-receipts in wm-mcp).
180pub trait PassGate: Send + Sync {
181    /// Verify a gate-lite pass token for `tool`; the error string is the
182    /// human-readable refusal reason.
183    fn verify(&self, token: &str, tool: &str) -> std::result::Result<PassEvidence, String>;
184}
185
186/// Pass enforcement mode (`WM_MANDALA_PASS`).
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub enum PassMode {
189    /// No pass gate attached: `mandala_pass` args are inert (S1 behavior).
190    #[default]
191    Off,
192    /// A supplied pass must verify; no pass keeps S1 behavior.
193    Optional,
194    /// Destructive dispatches must carry a valid pass.
195    Required,
196}
197
198impl PassMode {
199    /// Parse `WM_MANDALA_PASS=off|optional|required` (unknown → `Optional`, loud).
200    #[must_use]
201    pub fn from_env() -> Self {
202        match std::env::var("WM_MANDALA_PASS") {
203            Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
204                "off" | "0" | "false" => Self::Off,
205                "required" | "require" => Self::Required,
206                "optional" | "1" | "true" => Self::Optional,
207                other => {
208                    tracing::warn!(
209                        value = other,
210                        "WM_MANDALA_PASS is not off|optional|required — using optional"
211                    );
212                    Self::Optional
213                }
214            },
215            Err(_) => Self::Optional,
216        }
217    }
218}
219
220/// Evidence passed to the authority-seam hook after a destructive dispatch.
221pub struct AuthorityDispatch<'a> {
222    /// Tool route.
223    pub tool: &'a str,
224    /// Dispatch args (after pass-token removal).
225    pub args: &'a serde_json::Value,
226    /// Successful output (`None` on failure).
227    pub output: Option<&'a serde_json::Value>,
228    /// Whether the dispatch succeeded.
229    pub success: bool,
230    /// Dispatch elapsed time.
231    pub elapsed: Duration,
232    /// Verified pass evidence when a gate-lite pass authorized the dispatch.
233    pub pass: Option<&'a PassEvidence>,
234}
235
236/// Optional post-dispatch receipt hook (S1 receipts core).
237///
238/// Called after a **destructive** dispatch — the authority seam — on success,
239/// and on failure when a pass was presented (the receipt then records an
240/// `error` termination). Implementations must be non-failing (log their own
241/// errors) and must never alter the dispatch result. The disabled default
242/// (`None`) costs one `Option::is_some` check on the success path.
243pub trait ReceiptDispatchHook: Send + Sync {
244    /// Called after a destructive dispatch at the authority seam.
245    fn on_authority_dispatch(&self, dispatch: AuthorityDispatch<'_>);
246}
247
248/// The dispatch pipeline processes tool calls through governance,
249/// rate limiting, circuit breaking, and karma tracking before and after
250/// the actual tool execution.
251pub struct DispatchPipeline {
252    rate_limiter: Arc<RateLimiter>,
253    circuit_breakers: Arc<CircuitBreakerRegistry>,
254    dharma_gate: Arc<DharmaGate>,
255    karma_ledger: Option<Arc<KarmaLedger>>,
256    /// Optional ResourceRules (Yama) — write/spawn/network budgets, novelty,
257    /// purpose, and human-review gates evaluated on the dispatch path.
258    resource_rules: Option<Arc<ResourceRules>>,
259    /// Optional write gate (V8 S5 stage 2c) — junk filter, dedup gate, and
260    /// class plausibility ceilings/floors on the memory-create path.
261    write_gate: Option<Arc<crate::write_gate::WriteGate>>,
262    /// Optional write-audit journal — append-only record of declared vs
263    /// actual store mutations per dispatch.
264    write_audit: Option<Arc<wm_governance::WriteAuditJournal>>,
265    /// Optional secret scanner (P-PROV-5/B(c)) — warn-only credential-shape
266    /// sampling over successful dispatch outputs. `None` disables.
267    secret_scan: Option<crate::secret_scan::SharedSampler>,
268    /// Optional scoped-thread sandbox executor (P-SANDBOX-3, Landlock v1) —
269    /// `StoreScoped` tools run confined on a fresh thread. `None` = the
270    /// declared flag is inert (v0 whole-process ruleset may still apply).
271    sandbox_exec: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
272    /// Optional subprocess spawn sandbox registry (B2) — tools declaring
273    /// `Sandbox::Subprocess` get a runner-backed
274    /// [`wm_core::sandbox::SpawnPolicy`] injected into their context;
275    /// counters and the active-runner disclosure ride the dispatch.
276    /// `None` = the declarations are inert (Landlock v1 doctrine).
277    subprocess_sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
278    /// Optional flight recorder (Q35b) — opt-in JSONL payload capture for
279    /// replay. Captures at the same point as `args_digest` so sidecar args
280    /// always hash to the journal digest (the replay identity gate).
281    flight_recorder: Option<Arc<crate::flight::FlightRecorder>>,
282    /// The firebreak — forbidden-command guardrail (P1.4) + bulk-scope law
283    /// (P1.6). Armed by default on every construction path; see
284    /// [`wm_governance::Firebreak`].
285    firebreak: Option<Arc<wm_governance::Firebreak>>,
286    /// Capability gate (PLAN_F F-1, dispatch half) — maps `EffectRow.invokes`
287    /// onto governance capabilities and verifies any engagement credential
288    /// presented under `args["_engagement"]`. Advisory by default; strict via
289    /// `WM_REQUIRE_CAPABILITIES=1`.
290    capability_mode: CapabilityGateMode,
291    /// Optional GanaRegistry for tracking co-usage patterns (Phase 6)
292    gana_registry: Option<Arc<std::sync::Mutex<wm_core::GanaRegistry>>>,
293    /// Optional upper bound on tool execution. When a call exceeds it, the
294    /// future is dropped and a `CoreError::Tool` timeout error is returned, so
295    /// one hung tool can't wedge the server's event loop or block shutdown.
296    dispatch_timeout: Option<Duration>,
297    /// Optional receipt-emission hook at the authority seam (destructive
298    /// dispatches). `None` (default) keeps the path unchanged; attached
299    /// explicitly by the deployment via `WM_RECEIPTS_AUTOEMIT=1`.
300    receipt_hook: Option<Arc<dyn ReceiptDispatchHook>>,
301    /// Optional gate-lite pass verifier (S2) and its enforcement mode.
302    pass_gate: Option<Arc<dyn PassGate>>,
303    pass_mode: PassMode,
304}
305
306impl DispatchPipeline {
307    /// Create a new dispatch pipeline with the given components.
308    ///
309    /// Not `const`: the default-armed firebreak is built here (pattern
310    /// sets compile once per pipeline).
311    pub fn new(
312        rate_limiter: Arc<RateLimiter>,
313        circuit_breakers: Arc<CircuitBreakerRegistry>,
314        dharma_gate: Arc<DharmaGate>,
315        karma_ledger: Option<Arc<KarmaLedger>>,
316    ) -> Self {
317        Self {
318            rate_limiter,
319            circuit_breakers,
320            dharma_gate,
321            karma_ledger,
322            resource_rules: None,
323            write_gate: None,
324            write_audit: None,
325            flight_recorder: None,
326            // The secret scanner is on by default like the firebreak: a
327            // tripwire you must remember to attach is not a tripwire.
328            // Warn-only at a deterministic 1-in-N cadence — it observes,
329            // never blocks. Override with `with_secret_scan_option`.
330            secret_scan: Some(Arc::new(crate::secret_scan::SecretSampler::from_env())),
331            // The per-tool sandbox executor is attached explicitly by the
332            // deployment (wm-mcp injects the Landlock callback when
333            // WM_LANDLOCK_V1=1); without it, StoreScoped marks are inert.
334            sandbox_exec: None,
335            // Same doctrine for the B2 subprocess registry: attached
336            // explicitly by the deployment (wm-mcp injects a detected
337            // runner); without it, Subprocess marks are inert.
338            subprocess_sandbox: None,
339            // The firebreak arms by default: every construction path (server,
340            // daemon, CLI, tests) inherits the veto + scope law unless it is
341            // explicitly disarmed with `with_firebreak_option(None)` or the
342            // `WM_FIREBREAK=0` kill-switch. A guardrail you must remember to
343            // attach is not a guardrail.
344            firebreak: Some(Arc::new(wm_governance::Firebreak::promoted())),
345            capability_mode: CapabilityGateMode::from_env(),
346            gana_registry: None,
347            dispatch_timeout: None,
348            receipt_hook: None,
349            pass_gate: None,
350            pass_mode: PassMode::Off,
351        }
352    }
353
354    /// Parse the dispatch timeout from `WM_DISPATCH_TIMEOUT_MS`.
355    ///
356    /// Unset → [`DEFAULT_DISPATCH_TIMEOUT`]; `0` → disabled; other values are
357    /// milliseconds. Invalid values fall back to the default.
358    #[must_use]
359    pub fn timeout_from_env() -> Option<Duration> {
360        match std::env::var("WM_DISPATCH_TIMEOUT_MS") {
361            Ok(v) => match v.trim().parse::<u64>() {
362                Ok(0) => None,
363                Ok(ms) => Some(Duration::from_millis(ms)),
364                Err(_) => {
365                    tracing::warn!(
366                        value = %v,
367                        "WM_DISPATCH_TIMEOUT_MS is not a valid millisecond count — using default"
368                    );
369                    Some(DEFAULT_DISPATCH_TIMEOUT)
370                }
371            },
372            Err(_) => Some(DEFAULT_DISPATCH_TIMEOUT),
373        }
374    }
375
376    /// Bound tool execution with a timeout (`None` disables the bound).
377    #[must_use]
378    pub const fn with_dispatch_timeout(mut self, timeout: Option<Duration>) -> Self {
379        self.dispatch_timeout = timeout;
380        self
381    }
382
383    /// Create a pipeline with default components and no karma ledger.
384    #[must_use]
385    pub fn with_defaults() -> Self {
386        Self::new(
387            Arc::new(RateLimiter::default()),
388            Arc::new(CircuitBreakerRegistry::default()),
389            Arc::new(DharmaGate::default()),
390            None,
391        )
392    }
393
394    /// Override the capability-gate mode (tests, deliberate strict runs).
395    #[must_use]
396    pub const fn with_capability_mode(mut self, mode: CapabilityGateMode) -> Self {
397        self.capability_mode = mode;
398        self
399    }
400
401    /// Attach a GanaRegistry for co-usage tracking (Phase 6).
402    #[must_use]
403    pub fn with_gana_registry(
404        mut self,
405        registry: Arc<std::sync::Mutex<wm_core::GanaRegistry>>,
406    ) -> Self {
407        self.gana_registry = Some(registry);
408        self
409    }
410
411    /// Attach ResourceRules (Yama) — evaluated on every dispatch.
412    #[must_use]
413    pub fn with_resource_rules(mut self, rules: Arc<ResourceRules>) -> Self {
414        self.resource_rules = Some(rules);
415        self
416    }
417
418    /// Attach the write gate (V8 S5 stage 2c) — runs between resource
419    /// rules and the rate limiter: junk filter, dedup short-circuit, and
420    /// class plausibility ceilings/floors on the memory-create path.
421    #[must_use]
422    pub fn with_write_gate(mut self, gate: Arc<crate::write_gate::WriteGate>) -> Self {
423        self.write_gate = Some(gate);
424        self
425    }
426
427    /// Attach a write-audit journal — every dispatch appends a journal entry
428    /// recording declared vs actual store mutations.
429    #[must_use]
430    pub fn with_write_audit(mut self, journal: Arc<wm_governance::WriteAuditJournal>) -> Self {
431        self.write_audit = Some(journal);
432        self
433    }
434
435    /// Attach a flight recorder (Q35b replay capture). OFF by default.
436    /// Capture point matches `args_digest` (post-gate, pre-call) so the
437    /// sidecar is always digest-aligned with the journal.
438    #[must_use]
439    pub fn with_flight_recorder(
440        mut self,
441        recorder: Option<Arc<crate::flight::FlightRecorder>>,
442    ) -> Self {
443        self.flight_recorder = recorder;
444        self
445    }
446
447    /// Replace the default secret scanner — `None` disables output
448    /// sampling entirely for this pipeline (tests, special constructions).
449    #[must_use]
450    pub fn with_secret_scan_option(
451        mut self,
452        scanner: Option<crate::secret_scan::SharedSampler>,
453    ) -> Self {
454        self.secret_scan = scanner;
455        self
456    }
457
458    /// The secret scanner attached to this pipeline (if any).
459    #[must_use]
460    pub fn secret_scan(&self) -> Option<&crate::secret_scan::SecretSampler> {
461        self.secret_scan.as_deref()
462    }
463
464    /// Attach an optional receipt-emission hook (S1 receipts core). Called
465    /// after each successful destructive dispatch; `None` (default) leaves
466    /// the dispatch path unchanged.
467    #[must_use]
468    pub fn with_receipt_hook_option(mut self, hook: Option<Arc<dyn ReceiptDispatchHook>>) -> Self {
469        self.receipt_hook = hook;
470        self
471    }
472
473    /// The receipt hook attached to this pipeline (if any).
474    #[must_use]
475    pub fn receipt_hook(&self) -> Option<&Arc<dyn ReceiptDispatchHook>> {
476        self.receipt_hook.as_ref()
477    }
478
479    /// Attach a gate-lite pass verifier and its enforcement mode (S2).
480    /// `None` (default) makes `mandala_pass` args inert.
481    #[must_use]
482    pub fn with_pass_gate_option(
483        mut self,
484        gate: Option<Arc<dyn PassGate>>,
485        mode: PassMode,
486    ) -> Self {
487        self.pass_gate = gate;
488        self.pass_mode = mode;
489        self
490    }
491
492    /// The pass gate attached to this pipeline (if any).
493    #[must_use]
494    pub fn pass_gate(&self) -> Option<&Arc<dyn PassGate>> {
495        self.pass_gate.as_ref()
496    }
497
498    /// The pass enforcement mode.
499    #[must_use]
500    pub const fn pass_mode(&self) -> PassMode {
501        self.pass_mode
502    }
503
504    /// Attach the scoped-thread sandbox executor (P-SANDBOX-3). When
505    /// attached, tools declaring `Sandbox::StoreScoped` run on a confined
506    /// fresh thread; everything else keeps the ambient path.
507    #[must_use]
508    pub fn with_sandbox_executor(
509        mut self,
510        executor: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
511    ) -> Self {
512        self.sandbox_exec = executor;
513        self
514    }
515
516    /// The sandbox executor attached to this pipeline (if any).
517    #[must_use]
518    pub fn sandbox_executor(&self) -> Option<&crate::sandbox_exec::ScopedSandboxExecutor> {
519        self.sandbox_exec.as_deref()
520    }
521
522    /// Attach the subprocess spawn sandbox registry (B2). When attached,
523    /// `Sandbox::Subprocess` tools receive a runner-backed spawn policy in
524    /// their context; anything short of an active runner loud-degrades.
525    #[must_use]
526    pub fn with_subprocess_sandbox(
527        mut self,
528        sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
529    ) -> Self {
530        self.subprocess_sandbox = sandbox;
531        self
532    }
533
534    /// The subprocess spawn sandbox registry attached to this pipeline.
535    #[must_use]
536    pub fn subprocess_sandbox(&self) -> Option<&crate::subprocess_sandbox::SubprocessSandbox> {
537        self.subprocess_sandbox.as_deref()
538    }
539
540    /// Attach a firebreak with an explicit arm state (tests, special
541    /// constructions) — see [`Self::with_firebreak_option`].
542    #[must_use]
543    pub fn with_firebreak(mut self, firebreak: Arc<wm_governance::Firebreak>) -> Self {
544        self.firebreak = Some(firebreak);
545        self
546    }
547
548    /// Replace the default-armed firebreak — `None` disarms it entirely
549    /// for this pipeline (the `WM_FIREBREAK=0` env kill-switch operates
550    /// inside [`wm_governance::Firebreak::promoted`] and is the normal
551    /// off switch; this builder is for tests and special constructions).
552    #[must_use]
553    pub fn with_firebreak_option(
554        mut self,
555        firebreak: Option<Arc<wm_governance::Firebreak>>,
556    ) -> Self {
557        self.firebreak = firebreak;
558        self
559    }
560
561    /// The firebreak attached to this pipeline (if any).
562    #[must_use]
563    pub fn firebreak(&self) -> Option<&wm_governance::Firebreak> {
564        self.firebreak.as_deref()
565    }
566
567    /// Optional variant of [`Self::with_write_audit`] — read-only servers
568    /// pass `None` because journaling is itself an LMDB write.
569    #[must_use]
570    pub fn with_write_audit_option(
571        mut self,
572        journal: Option<Arc<wm_governance::WriteAuditJournal>>,
573    ) -> Self {
574        self.write_audit = journal;
575        self
576    }
577
578    /// The resource rules attached to this pipeline (if any).
579    #[must_use]
580    pub fn resource_rules(&self) -> Option<&ResourceRules> {
581        self.resource_rules.as_deref()
582    }
583
584    /// The write-audit journal attached to this pipeline (if any).
585    #[must_use]
586    pub fn write_audit(&self) -> Option<&wm_governance::WriteAuditJournal> {
587        self.write_audit.as_deref()
588    }
589
590    /// Dispatch a tool call through the full pipeline.
591    pub async fn dispatch(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
592        let start = Instant::now();
593        let mut args = args;
594
595        // 1. Effect check — brain-wave compatibility
596        // Explicit `confirm: true` (resolved here, before every gate, so
597        // deliberate operator intent is visible downstream) bypasses the
598        // eco-mode availability restriction: eco mode conserves autonomous
599        // resources, and a confirmed destructive action is deliberate, not
600        // autonomous. The coherence gate below stays absolute (9.1.6).
601        let confirmed = args
602            .get("confirm")
603            .and_then(serde_json::Value::as_bool)
604            .unwrap_or(false);
605        ctx.explicit_confirm = confirmed;
606        if !tool.effects().is_available_in(ctx.brain_wave) && !confirmed {
607            return Err(CoreError::Governance(format!(
608                "tool '{}' not available in {:?} brain-wave state",
609                tool.name(),
610                ctx.brain_wave
611            )));
612        }
613
614        // 1b. Coherence gate — refuse writes when citta coherence is low
615        const COHERENCE_THRESHOLD: f32 = 0.3;
616        if !tool.effects().writes.is_empty() && ctx.citta_coherence < COHERENCE_THRESHOLD {
617            return Err(CoreError::Governance(format!(
618                "tool '{}' requires write access but citta coherence is {:.2} (minimum {:.2})",
619                tool.name(),
620                ctx.citta_coherence,
621                COHERENCE_THRESHOLD
622            )));
623        }
624
625        // 1c. Read-only gate — server-level `--readonly` refuses every tool
626        // that declares writes, whether dispatched directly or through the
627        // `wm` meta-tool.
628        if ctx.readonly && !tool.effects().writes.is_empty() {
629            return Err(CoreError::Governance(format!(
630                "server is read-only: tool '{}' requires write access",
631                tool.name()
632            )));
633        }
634
635        // 1c. Self-model confidence — conservative dispatch when confidence is low
636        const CONFIDENCE_THRESHOLD: f32 = 0.5;
637        if ctx.self_model_confidence < CONFIDENCE_THRESHOLD {
638            tracing::warn!(
639                tool = tool.name(),
640                confidence = ctx.self_model_confidence,
641                "low self-model confidence — conservative dispatch mode"
642            );
643            // Block write operations when confidence is low — can't trust side effects
644            if !tool.effects().writes.is_empty() {
645                return Err(CoreError::Governance(format!(
646                    "homeostasis limit (self-model confidence): tool '{}' requires write access but confidence is {:.2} (minimum {:.2}) — conservative dispatch blocks writes; this is load-sensitive, retry when the host settles (deterministic runs can pin WM_HOMEOSTASIS_FROZEN=1)",
647                    tool.name(),
648                    ctx.self_model_confidence,
649                    CONFIDENCE_THRESHOLD
650                )));
651            }
652        }
653
654        // 1d. Drive caution gate — warn on high-caution write operations
655        const DRIVE_CAUTION_THRESHOLD: f32 = 0.85;
656        if !tool.effects().writes.is_empty() && ctx.drive_caution > DRIVE_CAUTION_THRESHOLD {
657            tracing::warn!(
658                tool = tool.name(),
659                drive_caution = ctx.drive_caution,
660                "high drive caution — write operation flagged for review"
661            );
662        }
663
664        // 1e. Drive energy gate — warn on low-energy write operations
665        const DRIVE_ENERGY_THRESHOLD: f32 = 0.15;
666        if !tool.effects().writes.is_empty() && ctx.drive_energy < DRIVE_ENERGY_THRESHOLD {
667            tracing::warn!(
668                tool = tool.name(),
669                drive_energy = ctx.drive_energy,
670                "low drive energy — write operation may be resource-constrained"
671            );
672        }
673
674        // 1f. Capability gate (PLAN_F F-1, dispatch half) — the tool's
675        // declared `invokes` must be covered by a presented engagement
676        // credential. Presenting a credential always triggers cryptographic
677        // verification (signature → revocation → expiry → scope coverage);
678        // missing credentials are advisory by default and refused under
679        // `WM_REQUIRE_CAPABILITIES=1`. The credential key is stripped from
680        // args so tokens never reach tool bodies or audit digests.
681        match crate::capability_gate::evaluate(
682            tool.effects(),
683            &mut args,
684            self.capability_mode,
685            chrono::Utc::now().timestamp(),
686        ) {
687            Ok(GateOutcome::AdvisoryMissing { required }) => {
688                tracing::debug!(
689                    tool = tool.name(),
690                    required = %required.labels().join(", "),
691                    mode = self.capability_mode.label(),
692                    "capability gate: requirement unmet (advisory)"
693                );
694            }
695            Ok(_) => {}
696            Err(reason) => {
697                return Err(CoreError::Governance(format!("capability gate: {reason}")));
698            }
699        }
700
701        // 2. Dharma gate — ethical governance
702        // (`confirmed` was resolved at step 1; the confirm gate in 4b
703        // re-uses the same value.)
704        let verdict = self.dharma_gate.evaluate(tool.effects(), ctx);
705        match verdict {
706            ActionVerdict::Panic(reason) => {
707                tracing::error!(tool = tool.name(), reason = %reason, "Dharma PANIC");
708                return Err(CoreError::Governance(reason));
709            }
710            ActionVerdict::Intervene(reason) => {
711                tracing::warn!(tool = tool.name(), reason = %reason, "Dharma INTERVENE");
712                return Err(CoreError::Governance(reason));
713            }
714            ActionVerdict::Correct(reason) => {
715                tracing::info!(tool = tool.name(), reason = %reason, "Dharma CORRECT — proceeding with restrictions");
716            }
717            ActionVerdict::Advise(reason) => {
718                tracing::debug!(tool = tool.name(), reason = %reason, "Dharma ADVISE");
719            }
720            ActionVerdict::Observe => {}
721        }
722
723        // 2b. Resource rules (Yama) — budgets, novelty, purpose, human review.
724        //
725        // Budget violations and autonomous human-review/purpose violations
726        // block the dispatch. Novelty flags are non-blocking: they are
727        // attached to the response so the caller can see the repetition.
728        let mut novelty_flag: Option<String> = None;
729        if let Some(ref rules) = self.resource_rules {
730            let effects = tool.effects();
731            let is_write = !effects.writes.is_empty();
732            let is_spawn = effects.spawns
733                || effects
734                    .writes
735                    .iter()
736                    .chain(effects.reads.iter())
737                    .any(|r| matches!(r, wm_core::Resource::Process));
738            let is_network = effects
739                .writes
740                .iter()
741                .chain(effects.reads.iter())
742                .any(|r| matches!(r, wm_core::Resource::Network));
743            let has_purpose = [args.get("purpose"), ctx.meta.get("purpose")]
744                .into_iter()
745                .flatten()
746                .filter_map(serde_json::Value::as_str)
747                .any(|p| !p.trim().is_empty());
748            let homeostasis = self.dharma_gate.homeostasis();
749            let verdict = rules.evaluate(
750                tool.name(),
751                hash_args(&args),
752                is_write,
753                is_spawn,
754                is_network,
755                has_purpose,
756                &homeostasis,
757                ctx.brain_wave,
758            );
759            match verdict {
760                ResourceVerdict::Allow => {}
761                ResourceVerdict::NotNovel { .. } => {
762                    novelty_flag = Some(verdict.reason());
763                    tracing::warn!(
764                        tool = tool.name(),
765                        reason = %verdict.reason(),
766                        "resource rules: novelty flag on response"
767                    );
768                }
769                ResourceVerdict::BudgetExceeded { .. }
770                | ResourceVerdict::RequiresHumanReview { .. }
771                | ResourceVerdict::NoPurpose { .. } => {
772                    tracing::warn!(
773                        tool = tool.name(),
774                        reason = %verdict.reason(),
775                        "resource rules: dispatch blocked"
776                    );
777                    return Err(CoreError::Governance(format!(
778                        "resource rules: {}",
779                        verdict.reason()
780                    )));
781                }
782            }
783        }
784
785        // 2c. Write gate (V8 S5, MEMORY_TYPOLOGY §3) — junk filter, dedup
786        // short-circuit, and class plausibility ceilings/floors on the
787        // memory-create path. Sits after Yama (budgets gate the caller's
788        // rights) and before rate limiting (the gate may rewrite args or
789        // short-circuit, which must not consume rate budget).
790        let gate_disclosure: Option<serde_json::Value> = if let Some(ref gate) = self.write_gate {
791            let outcome = gate.enforce(tool.name(), &mut args)?;
792            if let Some(sc) = outcome.short_circuit {
793                return Ok(sc);
794            }
795            outcome.disclosure
796        } else {
797            None
798        };
799
800        // 3. Rate limit
801        //
802        // Categories are named explicitly: the dispatch request-rate governor
803        // is NOT a write budget, a homeostasis limit, or a circuit breaker.
804        // Collapsing them all under "rate limited" made a healthy system look
805        // like a broken transport (2026-09-15 audit).
806        if let Err(retry_after_ms) = self.rate_limiter.try_acquire(tool.name()) {
807            return Err(CoreError::RateLimited(format!(
808                "request rate limit (per-tool dispatch governor): '{}' — retry after {}ms",
809                tool.name(),
810                retry_after_ms
811            )));
812        }
813
814        // 4. Circuit breaker
815        if self.circuit_breakers.is_open(tool.name()) {
816            let retry_after_ms = self
817                .circuit_breakers
818                .remaining_cooldown(tool.name())
819                .as_millis();
820            return Err(CoreError::CircuitBreaker(format!(
821                "{} — repeated execution failures opened the breaker; retry after {}ms",
822                tool.name(),
823                retry_after_ms
824            )));
825        }
826
827        // 4b. Destructive tool confirmation — requires explicit `confirm: true` in args
828        // (`confirmed` was resolved above, before the Dharma gate).
829        let confirm_gated = if tool.effects().destructive {
830            if !confirmed {
831                return Err(CoreError::Governance(format!(
832                    "tool '{}' is destructive — pass `\"confirm\": true` in args to proceed",
833                    tool.name()
834                )));
835            }
836            // The delete-confirm audit field (P1.6): the journal entry for
837            // this dispatch records that the caller confirmed.
838            Some(true)
839        } else {
840            None
841        };
842
843        // 4b-bis. Mandala pass gate (S2) — an optional gate-lite pass
844        // authorizes a destructive dispatch. Verification is offline; the
845        // evidence rides to the authority-seam hook and the token is removed
846        // from args so it never reaches the tool or the args digest.
847        let mut pass_evidence: Option<PassEvidence> = None;
848        if let Some(ref gate) = self.pass_gate {
849            let token = args
850                .get("mandala_pass")
851                .and_then(serde_json::Value::as_str)
852                .map(str::to_string);
853            if let Some(token) = token {
854                if let Some(object) = args.as_object_mut() {
855                    object.remove("mandala_pass");
856                }
857                if tool.effects().destructive {
858                    match gate.verify(&token, tool.name()) {
859                        Ok(evidence) => pass_evidence = Some(evidence),
860                        Err(reason) => {
861                            return Err(CoreError::Governance(format!(
862                                "mandala pass refused for '{}': {reason}",
863                                tool.name()
864                            )));
865                        }
866                    }
867                }
868            } else if tool.effects().destructive && self.pass_mode == PassMode::Required {
869                return Err(CoreError::Governance(format!(
870                    "tool '{}' requires a `mandala_pass` (WM_MANDALA_PASS=required)",
871                    tool.name()
872                )));
873            }
874        }
875
876        // 4c. Firebreak — the promoted Jan-11 forbidden-command guardrail
877        // (P1.4) plus the bulk-scope law (P1.6, the Jul-13 lesson). Blocks
878        // before execution: forbidden patterns veto even a confirmed call;
879        // dangerous patterns demand explicit confirm; destructive tools
880        // must carry a scope their registry rule accepts. See
881        // `wm_governance::firebreak` for the doctrine and scoping (the
882        // veto gates the irreversible seam, never prose).
883        let mut firebreak_advisories: Vec<String> = Vec::new();
884        if let Some(ref firebreak) = self.firebreak {
885            match firebreak.enforce(tool.name(), tool.effects(), &args) {
886                FirebreakOutcome::Blocked(reason) => {
887                    tracing::warn!(tool = tool.name(), reason = %reason, "firebreak VETO");
888                    return Err(CoreError::Governance(reason));
889                }
890                FirebreakOutcome::Proceed { advisories } if !advisories.is_empty() => {
891                    tracing::info!(tool = tool.name(), advisories = ?advisories, "firebreak advisories");
892                    firebreak_advisories = advisories;
893                }
894                FirebreakOutcome::Proceed { .. } => {}
895            }
896        }
897
898        // 4d. Compartment access control — check declared galaxy reads/writes
899        //        plus runtime galaxy argument from tool args.
900        //
901        //        Tools like memory.read accept a `galaxy` argument at runtime that
902        //        may differ from the default galaxy declared in their EffectRow.
903        //        We check both the static declarations and the runtime argument
904        //        to prevent compartment bypass via runtime galaxy selection.
905        //
906        //        When a runtime `galaxy` argument is present, the tool's galaxy
907        //        effects are runtime-directed, so the static loop defers to the
908        //        runtime check below — a set-covering declaration (all memory
909        //        galaxies) must not require access to galaxies the call never
910        //        touches.
911        let has_runtime_galaxy = args
912            .get("galaxy")
913            .and_then(serde_json::Value::as_str)
914            .is_some_and(|g| !g.is_empty());
915        let mut checked_galaxies: Vec<wm_core::Galaxy> = Vec::new();
916
917        if !has_runtime_galaxy {
918            for resource in &tool.effects().reads {
919                if let wm_core::Resource::Galaxy(name) = resource {
920                    if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
921                        if !ctx.can_access_galaxy(galaxy) {
922                            return Err(CoreError::Governance(format!(
923                                "compartment '{}' cannot read galaxy '{}' (tool '{}')",
924                                ctx.compartment.as_deref().unwrap_or("none"),
925                                name,
926                                tool.name()
927                            )));
928                        }
929                        checked_galaxies.push(galaxy);
930                    }
931                }
932            }
933            for resource in &tool.effects().writes {
934                if let wm_core::Resource::Galaxy(name) = resource {
935                    if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
936                        if !ctx.can_write_galaxy(galaxy) {
937                            return Err(CoreError::Governance(format!(
938                                "compartment '{}' cannot write to galaxy '{}' (tool '{}')",
939                                ctx.compartment.as_deref().unwrap_or("none"),
940                                name,
941                                tool.name()
942                            )));
943                        }
944                        checked_galaxies.push(galaxy);
945                    }
946                }
947            }
948        }
949
950        // Check runtime `galaxy` argument if present and not already checked
951        if let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str) {
952            if !galaxy_str.is_empty() {
953                if let Some(runtime_galaxy) = wm_core::Galaxy::from_db_name(galaxy_str) {
954                    if !checked_galaxies.contains(&runtime_galaxy) {
955                        // Determine if this is a read or write based on EffectRow writes
956                        let has_writes = !tool.effects().writes.is_empty();
957                        if has_writes {
958                            if !ctx.can_write_galaxy(runtime_galaxy) {
959                                return Err(CoreError::Governance(format!(
960                                    "compartment '{}' cannot write to galaxy '{}' (tool '{}' runtime arg)",
961                                    ctx.compartment.as_deref().unwrap_or("none"),
962                                    galaxy_str,
963                                    tool.name()
964                                )));
965                            }
966                        } else if !ctx.can_access_galaxy(runtime_galaxy) {
967                            return Err(CoreError::Governance(format!(
968                                "compartment '{}' cannot read galaxy '{}' (tool '{}' runtime arg)",
969                                ctx.compartment.as_deref().unwrap_or("none"),
970                                galaxy_str,
971                                tool.name()
972                            )));
973                        }
974                    }
975                }
976            }
977        }
978
979        // 4d. Runtime Satya check — a runtime `galaxy` argument can redirect
980        // a write to citta even when the static declaration doesn't name it.
981        // Writing the consciousness stream without reading evidence is
982        // fabrication; the static Dharma rule can't see the runtime argument,
983        // so the pipeline enforces the same rule here.
984        if !tool.effects().writes.is_empty()
985            && let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str)
986            && galaxy_str == "citta"
987            && !tool
988                .effects()
989                .reads
990                .iter()
991                .any(|r| matches!(r, wm_core::Resource::Galaxy(g) if g == "citta"))
992        {
993            return Err(CoreError::Governance(
994                "VIOLATION_SATYA: writing to citta (runtime galaxy) without reading — memory fabrication is forbidden"
995                    .to_string(),
996            ));
997        }
998
999        // 5. Tool call — optionally bounded so a hung tool can't wedge the
1000        // server's event loop or delay graceful shutdown.
1001        //
1002        // Capture identifying args first (consumed by the call below) so the
1003        // write-audit journal can record which memory was touched, and
1004        // sample the store mutation counter so the entry covers exactly
1005        // this dispatch's window.
1006        let args_memory_id = first_str(&args, &["id", "memory_id", "memory"]);
1007        let args_content_hash = first_str(&args, &["content_hash", "hash", "sha256"]);
1008        // Q35b flight-recorder: digest the dispatch input (route identity +
1009        // arg keys + value hashes, no raw values) so journal entries can
1010        // answer "what went in" — replay verification without storing
1011        // untrusted payloads verbatim in the audit trail.
1012        let args_digest = wm_governance::args_digest(tool.name(), &args);
1013        // Flight capture at the SAME point (post-gate, pre-call): the
1014        // sidecar args must hash to the journal digest, or replay's
1015        // identity gate is meaningless. Recorded regardless of outcome —
1016        // the journal does the same, and failed dispatches are part of
1017        // the session being reproduced.
1018        if let Some(ref flight) = self.flight_recorder {
1019            if let Err(e) = flight.record(tool.name(), &args) {
1020                tracing::warn!(error = %e, "Flight recorder capture failed (replay will refuse)");
1021            }
1022        }
1023        let write_audit_baseline = self
1024            .write_audit
1025            .as_ref()
1026            .map_or(0, |j| j.dispatch_baseline());
1027        // 4e. B2 subprocess spawn policy. Declared `Sandbox::Subprocess`
1028        // tools receive a runner-backed policy on their context *before*
1029        // the call; a declared tool with no runner resolvable still runs
1030        // (availability first) but is counted and warned — and a tool that
1031        // declares raw `spawns` without the contract is surfaced once.
1032        let mut spawn_disclosure: Option<serde_json::Value> = None;
1033        if let Some(sb) = self.subprocess_sandbox.as_deref() {
1034            if crate::subprocess_sandbox::SubprocessSandbox::declared(tool.effects()) {
1035                let policy = sb.policy_for(tool.effects());
1036                if policy.is_active() {
1037                    let mut disclosure = serde_json::json!({
1038                        "net": policy.allow_net(),
1039                        "envelope": wm_core::sandbox::ENVELOPE_SCHEMA,
1040                    });
1041                    if let Some(runner) = policy.runner()
1042                        && let Some(obj) = disclosure.as_object_mut()
1043                    {
1044                        obj.insert(
1045                            "runner".to_string(),
1046                            serde_json::Value::String(runner.display().to_string()),
1047                        );
1048                    }
1049                    sb.note_confined();
1050                    spawn_disclosure = Some(disclosure);
1051                } else {
1052                    sb.note_degraded(tool.name());
1053                }
1054                ctx.spawn = policy;
1055            } else if tool.effects().spawns {
1056                sb.note_unconfined_spawn(tool.name());
1057            }
1058        }
1059        // P-SANDBOX-3 (Landlock v1): a `StoreScoped` tool with an executor
1060        // attached runs on a confined scoped thread (synchronous — see
1061        // `sandbox_exec` for why, and for the timeout-parity v1 gap).
1062        // The hook needs the post-pass args after `tool.call` consumes them.
1063        let authority_args = if self.receipt_hook.is_some() && tool.effects().destructive {
1064            Some(args.clone())
1065        } else {
1066            None
1067        };
1068        let null_output = serde_json::Value::Null;
1069        let result = if crate::sandbox_exec::ScopedSandboxExecutor::handles(tool)
1070            && let Some(executor) = self.sandbox_exec.as_deref()
1071        {
1072            executor.run(tool, ctx, args)
1073        } else if let Some(timeout) = self.dispatch_timeout {
1074            if let Ok(res) = tokio::time::timeout(timeout, tool.call(ctx, args)).await {
1075                res
1076            } else {
1077                tracing::error!(
1078                    tool = tool.name(),
1079                    timeout_ms = timeout.as_millis(),
1080                    "tool dispatch timed out"
1081                );
1082                self.circuit_breakers.record_failure(tool.name());
1083                return Err(CoreError::Tool(format!(
1084                    "tool '{}' timed out after {}ms",
1085                    tool.name(),
1086                    timeout.as_millis()
1087                )));
1088            }
1089        } else {
1090            tool.call(ctx, args).await
1091        };
1092        let elapsed = start.elapsed();
1093
1094        // 6b. Secret-scan sampling (P-PROV-5/B(c)) — warn-only
1095        // credential-shape scan over successful outputs. Deterministic
1096        // 1-in-N inside the sampler; content never logged, dispatch never
1097        // blocked. Failures are not scanned (v0 scope).
1098        if let Some(ref scanner) = self.secret_scan {
1099            if let Ok(ref output) = result {
1100                scanner.scan(tool.name(), output);
1101            }
1102        }
1103
1104        // Attach a non-blocking novelty flag so it reaches the response.
1105        let result = match (result, novelty_flag) {
1106            (Ok(mut output), Some(flag)) => {
1107                if let serde_json::Value::Object(ref mut map) = output {
1108                    match map.get_mut("resource_flags") {
1109                        Some(serde_json::Value::Array(arr)) => {
1110                            arr.push(serde_json::Value::String(flag));
1111                        }
1112                        Some(_) => {}
1113                        None => {
1114                            map.insert(
1115                                "resource_flags".to_string(),
1116                                serde_json::Value::Array(vec![serde_json::Value::String(flag)]),
1117                            );
1118                        }
1119                    }
1120                }
1121                Ok(output)
1122            }
1123            (result, _) => result,
1124        };
1125
1126        // Attach the write-gate disclosure the same way — a gate that
1127        // acts silently is a gate nobody can audit.
1128        let result = match (result, gate_disclosure) {
1129            (Ok(mut output), Some(disclosure)) => {
1130                if let serde_json::Value::Object(ref mut map) = output {
1131                    map.insert("write_gate".to_string(), disclosure);
1132                }
1133                Ok(output)
1134            }
1135            (result, _) => result,
1136        };
1137
1138        // Attach firebreak advisories the same way — a gate that acts
1139        // silently is a gate nobody can audit. Caution-class findings and
1140        // confirmed dangerous patterns surface under `firebreak.advisories`.
1141        let result = match (result, firebreak_advisories) {
1142            (Ok(mut output), advisories) if !advisories.is_empty() => {
1143                if let serde_json::Value::Object(ref mut map) = output {
1144                    map.insert(
1145                        "firebreak".to_string(),
1146                        serde_json::json!({ "advisories": advisories }),
1147                    );
1148                }
1149                Ok(output)
1150            }
1151            (result, _) => result,
1152        };
1153
1154        // Attach the subprocess-sandbox disclosure the same way — active
1155        // confinement on a declared spawn tool is announced, never silent.
1156        let result = match (result, spawn_disclosure) {
1157            (Ok(mut output), Some(disclosure)) => {
1158                if let serde_json::Value::Object(ref mut map) = output {
1159                    map.insert("sandbox".to_string(), disclosure);
1160                }
1161                Ok(output)
1162            }
1163            (result, _) => result,
1164        };
1165
1166        // 6. Stats + circuit breaker feedback + karma record + write audit
1167        if let Ok(output) = &result {
1168            tool.stats().record_success(elapsed, elapsed);
1169            self.circuit_breakers.record_success(tool.name());
1170
1171            if let Some(ref ledger) = self.karma_ledger {
1172                let declared_writes = !tool.effects().writes.is_empty();
1173                let actual_writes = output
1174                    .get("writes")
1175                    .and_then(|w| w.as_array())
1176                    .map_or(0, |a| a.len() as u32);
1177                if let Err(e) = ledger.record(tool.name(), declared_writes, actual_writes, true) {
1178                    tracing::warn!(error = %e, "Karma ledger record failed");
1179                }
1180                ctx.karma_debt = ledger.total_debt();
1181            }
1182
1183            if let Some(ref journal) = self.write_audit {
1184                let declared_writes = !tool.effects().writes.is_empty();
1185                record_write_audit(
1186                    journal,
1187                    write_audit_baseline,
1188                    tool.name(),
1189                    wm_governance::ActorIdentity::from_context(ctx),
1190                    declared_writes,
1191                    args_memory_id.as_deref(),
1192                    args_content_hash.as_deref(),
1193                    Some(args_digest),
1194                    output,
1195                    true,
1196                    confirm_gated,
1197                );
1198            }
1199
1200            // Authority-seam receipt hook (S1/S2): successful destructive
1201            // dispatches; disabled = one `Option` check.
1202            if tool.effects().destructive {
1203                if let Some(ref hook) = self.receipt_hook {
1204                    hook.on_authority_dispatch(AuthorityDispatch {
1205                        tool: tool.name(),
1206                        args: authority_args.as_ref().unwrap_or(&null_output),
1207                        output: Some(output),
1208                        success: true,
1209                        elapsed,
1210                        pass: pass_evidence.as_ref(),
1211                    });
1212                }
1213            }
1214        } else {
1215            tool.stats().record_failure(elapsed);
1216            // Breaker health is about the BACKEND, not the caller. A malformed
1217            // request that the tool correctly rejects must not fast-fail the
1218            // next valid request (2026-09-15 audit).
1219            if let Err(err) = &result {
1220                if err.counts_as_breaker_failure() {
1221                    self.circuit_breakers.record_failure(tool.name());
1222                }
1223            }
1224
1225            if let Some(ref ledger) = self.karma_ledger {
1226                let declared_writes = !tool.effects().writes.is_empty();
1227                if let Err(ke) = ledger.record(tool.name(), declared_writes, 0, false) {
1228                    tracing::warn!(error = %ke, "Karma ledger record failed");
1229                }
1230                ctx.karma_debt = ledger.total_debt();
1231            }
1232
1233            if let Some(ref journal) = self.write_audit {
1234                let declared_writes = !tool.effects().writes.is_empty();
1235                record_write_audit(
1236                    journal,
1237                    write_audit_baseline,
1238                    tool.name(),
1239                    wm_governance::ActorIdentity::from_context(ctx),
1240                    declared_writes,
1241                    args_memory_id.as_deref(),
1242                    args_content_hash.as_deref(),
1243                    Some(args_digest),
1244                    &serde_json::Value::Null,
1245                    false,
1246                    confirm_gated,
1247                );
1248            }
1249
1250            // Governed failures still leave evidence: a pass-authorized
1251            // destructive dispatch that failed emits an `error` termination.
1252            if tool.effects().destructive && pass_evidence.is_some() {
1253                if let Some(ref hook) = self.receipt_hook {
1254                    hook.on_authority_dispatch(AuthorityDispatch {
1255                        tool: tool.name(),
1256                        args: authority_args.as_ref().unwrap_or(&null_output),
1257                        output: None,
1258                        success: false,
1259                        elapsed,
1260                        pass: pass_evidence.as_ref(),
1261                    });
1262                }
1263            }
1264        }
1265
1266        // 6b. GanaRegistry — record usage and co-usage (Phase 6)
1267        if let Some(ref registry) = self.gana_registry {
1268            if let Ok(mut reg) = registry.lock() {
1269                let gana = tool.gana();
1270                reg.record_usage(gana, result.is_ok());
1271                // Record co-usage with the last Gana seen in this context
1272                if let Some(prev) = ctx.last_gana {
1273                    reg.record_co_usage(prev, gana);
1274                }
1275                ctx.last_gana = Some(gana);
1276            }
1277        }
1278
1279        result
1280    }
1281
1282    /// Dispatch a tool by name, looking it up in a registry.
1283    ///
1284    /// Convenience method that combines registry lookup with pipeline dispatch.
1285    /// Returns `NotFound` if the tool isn't registered.
1286    pub async fn dispatch_by_name(
1287        &self,
1288        registry: &crate::ToolRegistry,
1289        name: &str,
1290        ctx: &mut Context,
1291        args: Args,
1292    ) -> Result<Output> {
1293        let tool = registry
1294            .get(name)
1295            .ok_or_else(|| CoreError::NotFound(format!("tool '{name}' not registered")))?;
1296        self.dispatch(tool.as_ref(), ctx, args).await
1297    }
1298
1299    /// Access the rate limiter.
1300    #[must_use]
1301    pub fn rate_limiter(&self) -> &RateLimiter {
1302        &self.rate_limiter
1303    }
1304
1305    /// Access the circuit breaker registry.
1306    #[must_use]
1307    pub fn circuit_breakers(&self) -> &CircuitBreakerRegistry {
1308        &self.circuit_breakers
1309    }
1310
1311    /// Access the Dharma gate.
1312    #[must_use]
1313    pub fn dharma_gate(&self) -> &DharmaGate {
1314        &self.dharma_gate
1315    }
1316
1317    /// Access the karma ledger (if configured).
1318    #[must_use]
1319    pub fn karma_ledger(&self) -> Option<&KarmaLedger> {
1320        self.karma_ledger.as_deref()
1321    }
1322}
1323
1324impl Default for DispatchPipeline {
1325    fn default() -> Self {
1326        Self::with_defaults()
1327    }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use super::*;
1333    use wm_core::{BrainWave, EffectRow, Gana, Sandbox, ToolStats};
1334    use wm_governance::{ResourceRulesConfig, WriteAuditJournal};
1335
1336    struct TestTool {
1337        name: String,
1338        effects: EffectRow,
1339        stats: ToolStats,
1340        should_fail: bool,
1341        /// When set, `call` returns a fresh error of this class (error-class tests).
1342        error: Option<fn() -> CoreError>,
1343        output: Option<Output>,
1344        /// When set, the tool secretly writes one memory into this store —
1345        /// used to simulate a misdeclaring tool for the write-audit journal.
1346        store: Option<Arc<wm_memory::MemoryStore>>,
1347    }
1348
1349    impl TestTool {
1350        fn new(name: &str, effects: EffectRow) -> Self {
1351            Self {
1352                name: name.to_string(),
1353                effects,
1354                stats: ToolStats::default(),
1355                should_fail: false,
1356                error: None,
1357                output: None,
1358                store: None,
1359            }
1360        }
1361
1362        fn returning_error(name: &str, error: fn() -> CoreError) -> Self {
1363            Self {
1364                name: name.to_string(),
1365                effects: EffectRow::pure(),
1366                stats: ToolStats::default(),
1367                should_fail: false,
1368                error: Some(error),
1369                output: None,
1370                store: None,
1371            }
1372        }
1373
1374        fn with_output(mut self, output: Output) -> Self {
1375            self.output = Some(output);
1376            self
1377        }
1378
1379        fn with_store(mut self, store: Arc<wm_memory::MemoryStore>) -> Self {
1380            self.store = Some(store);
1381            self
1382        }
1383
1384        fn failing(name: &str) -> Self {
1385            Self {
1386                name: name.to_string(),
1387                effects: EffectRow::pure(),
1388                stats: ToolStats::default(),
1389                should_fail: true,
1390                error: None,
1391                output: None,
1392                store: None,
1393            }
1394        }
1395    }
1396
1397    #[async_trait]
1398    impl Tool for TestTool {
1399        fn name(&self) -> &str {
1400            &self.name
1401        }
1402        fn gana(&self) -> Gana {
1403            Gana::Heart
1404        }
1405        fn effects(&self) -> &EffectRow {
1406            &self.effects
1407        }
1408        async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1409            if let Some(store) = &self.store {
1410                let mem = wm_memory::Memory::new(
1411                    wm_core::Galaxy::Codex,
1412                    format!("misdeclared write from {}", self.name),
1413                );
1414                store.put(wm_core::Galaxy::Codex, &mem).ok();
1415            }
1416            if let Some(error) = self.error {
1417                Err(error())
1418            } else if self.should_fail {
1419                Err(CoreError::Tool(self.name.clone()))
1420            } else {
1421                Ok(self
1422                    .output
1423                    .clone()
1424                    .unwrap_or_else(|| serde_json::json!("ok")))
1425            }
1426        }
1427        fn stats(&self) -> &ToolStats {
1428            &self.stats
1429        }
1430    }
1431
1432    #[tokio::test]
1433    async fn pipeline_dispatch_success() {
1434        let pipeline = DispatchPipeline::with_defaults();
1435        let mut ctx = Context::new(BrainWave::Gamma);
1436        let tool = TestTool::new("test_tool", EffectRow::pure());
1437
1438        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1439        assert!(result.is_ok());
1440    }
1441
1442    struct HangingTool {
1443        effects: EffectRow,
1444        stats: ToolStats,
1445    }
1446
1447    impl HangingTool {
1448        fn new() -> Self {
1449            Self {
1450                effects: EffectRow::pure(),
1451                stats: ToolStats::default(),
1452            }
1453        }
1454    }
1455
1456    #[async_trait]
1457    impl Tool for HangingTool {
1458        fn name(&self) -> &str {
1459            "hanging_tool"
1460        }
1461        fn gana(&self) -> Gana {
1462            Gana::Heart
1463        }
1464        fn effects(&self) -> &EffectRow {
1465            &self.effects
1466        }
1467        async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1468            tokio::time::sleep(Duration::from_secs(30)).await;
1469            Ok(serde_json::json!("never reached"))
1470        }
1471        fn stats(&self) -> &ToolStats {
1472            &self.stats
1473        }
1474    }
1475
1476    #[tokio::test]
1477    async fn pipeline_dispatch_timeout_bounds_hung_tool() {
1478        let pipeline = DispatchPipeline::with_defaults()
1479            .with_dispatch_timeout(Some(Duration::from_millis(50)));
1480        let mut ctx = Context::new(BrainWave::Gamma);
1481        let tool = HangingTool::new();
1482
1483        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1484        assert!(result.is_err());
1485        let msg = result.err().unwrap().to_string();
1486        assert!(
1487            msg.contains("timed out"),
1488            "expected timeout error, got: {msg}"
1489        );
1490    }
1491
1492    #[tokio::test]
1493    async fn pipeline_dispatch_with_timeout_allows_fast_tool() {
1494        let pipeline = DispatchPipeline::with_defaults()
1495            .with_dispatch_timeout(Some(Duration::from_millis(500)));
1496        let mut ctx = Context::new(BrainWave::Gamma);
1497        let tool = TestTool::new("fast_tool", EffectRow::pure());
1498
1499        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1500        assert!(result.is_ok());
1501    }
1502
1503    #[tokio::test]
1504    async fn pipeline_dispatch_failure_records_stats() {
1505        let pipeline = DispatchPipeline::with_defaults();
1506        let mut ctx = Context::new(BrainWave::Gamma);
1507        let tool = TestTool::failing("failing_tool");
1508
1509        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1510        assert!(result.is_err());
1511        assert_eq!(
1512            tool.stats()
1513                .call_count
1514                .load(std::sync::atomic::Ordering::Relaxed),
1515            1
1516        );
1517    }
1518
1519    #[tokio::test]
1520    async fn pipeline_blocks_incompatible_brain_wave() {
1521        let pipeline = DispatchPipeline::with_defaults();
1522        let mut ctx = Context::new(BrainWave::Delta);
1523        let tool = TestTool::new("test_tool", EffectRow::pure());
1524
1525        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1526        assert!(result.is_err());
1527        match result {
1528            Err(CoreError::Governance(_)) => {}
1529            other => panic!("Expected Governance error, got {other:?}"),
1530        }
1531    }
1532
1533    #[tokio::test]
1534    async fn pipeline_dharma_blocks_destructive_in_strict_mode() {
1535        let pipeline = DispatchPipeline::with_defaults();
1536        let mut ctx = Context::new(BrainWave::Theta);
1537        let tool = TestTool::new(
1538            "destructive_tool",
1539            EffectRow {
1540                writes: vec![wm_core::Resource::Filesystem],
1541                ..Default::default()
1542            },
1543        );
1544
1545        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1546        assert!(result.is_err());
1547        match result {
1548            Err(CoreError::Governance(_)) => {}
1549            other => panic!("Expected Governance error, got {other:?}"),
1550        }
1551    }
1552
1553    #[tokio::test]
1554    async fn pipeline_strict_refusal_is_typed_and_distinct_from_starvation() {
1555        // Governance refusal (strict mode via system stress, Beta brain wave
1556        // so the availability gate admits the call): coordination lease
1557        // acquisition is refused with the typed AHIMSA violation.
1558        let pipeline = DispatchPipeline::with_defaults();
1559        pipeline
1560            .dharma_gate()
1561            .update_homeostasis(wm_governance::Homeostasis {
1562                cpu_load: 0.95,
1563                memory_pressure: 0.95,
1564                active: true,
1565            });
1566        let mut ctx = Context::new(BrainWave::Beta);
1567        let tool = TestTool::new(
1568            "stress_probe",
1569            EffectRow {
1570                reads: vec![wm_core::Resource::Filesystem],
1571                writes: vec![wm_core::Resource::CoordinationLease],
1572                ..Default::default()
1573            },
1574        );
1575        let governance = pipeline
1576            .dispatch(&tool, &mut ctx, Args::default())
1577            .await
1578            .expect_err("strict mode must refuse coordination lease acquisition");
1579        let text = governance.to_string();
1580        assert!(text.contains("VIOLATION_AHIMSA"), "{text}");
1581
1582        // First-run starvation: low self-model confidence refuses writes with
1583        // the typed homeostasis-limit error naming the frozen pin — a
1584        // different class from the governance refusal, and reads stay open.
1585        let pipeline = DispatchPipeline::with_defaults();
1586        let mut ctx = Context::new(BrainWave::Gamma);
1587        ctx.self_model_confidence = 0.3;
1588        let write_tool = TestTool::new(
1589            "stress_probe",
1590            EffectRow {
1591                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1592                ..Default::default()
1593            },
1594        );
1595        let starvation = pipeline
1596            .dispatch(&write_tool, &mut ctx, Args::default())
1597            .await
1598            .expect_err("low confidence must refuse writes");
1599        let text = starvation.to_string();
1600        assert!(text.contains("self-model confidence"), "{text}");
1601        assert!(text.contains("WM_HOMEOSTASIS_FROZEN"), "{text}");
1602        assert!(
1603            !text.contains("VIOLATION_AHIMSA"),
1604            "refusal classes must be distinguishable: {text}"
1605        );
1606
1607        let read_tool = TestTool::new(
1608            "stress_probe_read",
1609            EffectRow {
1610                reads: vec![wm_core::Resource::Galaxy("codex".into())],
1611                ..Default::default()
1612            },
1613        );
1614        assert!(
1615            pipeline
1616                .dispatch(&read_tool, &mut ctx, Args::default())
1617                .await
1618                .is_ok(),
1619            "starvation must not block reads"
1620        );
1621    }
1622
1623    #[tokio::test]
1624    async fn pipeline_dharma_confirm_passes_brain_wave_strict_for_destructive() {
1625        // 9.1.6: explicit `confirm: true` (deliberate operator intent)
1626        // passes the Theta/Delta brain-wave strict arm; stressed
1627        // homeostasis must still block (covered by dharma_gate unit tests).
1628        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(Arc::new(
1629            ResourceRules::new(ResourceRulesConfig {
1630                require_human_review: false,
1631                ..Default::default()
1632            }),
1633        ));
1634        let mut ctx = Context::new(BrainWave::Theta);
1635        let tool = TestTool::new(
1636            "destructive_tool",
1637            EffectRow {
1638                writes: vec![wm_core::Resource::Filesystem],
1639                ..Default::default()
1640            },
1641        );
1642        let result = pipeline
1643            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1644            .await;
1645        assert!(
1646            result.is_ok(),
1647            "confirmed destructive dispatch must pass brain-wave strict: {result:?}"
1648        );
1649    }
1650
1651    #[tokio::test]
1652    async fn pipeline_capability_gate_strict_blocks_uncredentialed() {
1653        let pipeline =
1654            DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1655        let mut ctx = Context::new(BrainWave::Gamma);
1656        let tool = TestTool::new(
1657            "capability_tool",
1658            EffectRow {
1659                invokes: vec![wm_core::Capability::MemoryWrite],
1660                ..Default::default()
1661            },
1662        );
1663
1664        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1665        match result {
1666            Err(CoreError::Governance(msg)) => {
1667                assert!(msg.contains("capability gate"), "{msg}");
1668                assert!(msg.contains("memory:write"), "{msg}");
1669            }
1670            other => panic!("Expected capability refusal, got {other:?}"),
1671        }
1672    }
1673
1674    #[tokio::test]
1675    async fn pipeline_capability_gate_strict_allows_valid_token() {
1676        let pipeline =
1677            DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1678        let mut ctx = Context::new(BrainWave::Gamma);
1679        let tool = TestTool::new(
1680            "capability_tool_ok",
1681            EffectRow {
1682                invokes: vec![wm_core::Capability::MemoryWrite],
1683                ..Default::default()
1684            },
1685        );
1686
1687        let mut issuer = wm_governance::engagement_tokens::EngagementIssuer::with_keypair(
1688            wm_governance::network_profile::AgentKeypair::from_seed([7u8; 32]),
1689        );
1690        let issuer_key = issuer.signer_public_key_hex();
1691        let token = issuer.issue(
1692            "tester",
1693            wm_governance::engagement_tokens::EngagementScope::Poc,
1694            "rules-hash",
1695            Some(3600),
1696        );
1697        let args = serde_json::json!({
1698            "_engagement": { "token": token, "issuer_public_key": issuer_key }
1699        });
1700
1701        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
1702        assert!(result.is_ok(), "valid Poc token should pass: {result:?}");
1703    }
1704
1705    #[tokio::test]
1706    async fn pipeline_capability_gate_advisory_allows_uncredentialed() {
1707        let pipeline =
1708            DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Advisory);
1709        let mut ctx = Context::new(BrainWave::Gamma);
1710        let tool = TestTool::new(
1711            "capability_tool_advisory",
1712            EffectRow {
1713                invokes: vec![wm_core::Capability::MemoryWrite],
1714                ..Default::default()
1715            },
1716        );
1717
1718        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1719        assert!(result.is_ok(), "advisory mode must not block: {result:?}");
1720    }
1721
1722    #[tokio::test]
1723    async fn pipeline_rate_limit_blocks_excess() {
1724        let rate_limiter = Arc::new(RateLimiter::new(1000, 2, 0));
1725        let pipeline = DispatchPipeline::new(
1726            rate_limiter,
1727            Arc::new(CircuitBreakerRegistry::default()),
1728            Arc::new(DharmaGate::default()),
1729            None,
1730        );
1731
1732        let mut ctx = Context::new(BrainWave::Gamma);
1733        let tool = TestTool::new("limited_tool", EffectRow::pure());
1734
1735        assert!(
1736            pipeline
1737                .dispatch(&tool, &mut ctx, Args::default())
1738                .await
1739                .is_ok()
1740        );
1741        assert!(
1742            pipeline
1743                .dispatch(&tool, &mut ctx, Args::default())
1744                .await
1745                .is_ok()
1746        );
1747        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1748        assert!(result.is_err());
1749        match result {
1750            Err(CoreError::RateLimited(_)) => {}
1751            other => panic!("Expected RateLimited error, got {other:?}"),
1752        }
1753    }
1754
1755    #[tokio::test]
1756    async fn pipeline_circuit_breaker_opens_on_repeated_failures() {
1757        let breakers = Arc::new(CircuitBreakerRegistry::new(
1758            crate::circuit_breaker::BreakerConfig {
1759                failure_threshold: 3,
1760                window: std::time::Duration::from_secs(10),
1761                cooldown: std::time::Duration::from_secs(30),
1762            },
1763        ));
1764        let pipeline = DispatchPipeline::new(
1765            Arc::new(RateLimiter::new(10000, 100, 100)),
1766            breakers.clone(),
1767            Arc::new(DharmaGate::default()),
1768            None,
1769        );
1770
1771        let mut ctx = Context::new(BrainWave::Gamma);
1772        let tool = TestTool::failing("flaky_tool");
1773
1774        for _ in 0..3 {
1775            let _ = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1776        }
1777
1778        assert_eq!(
1779            breakers.state("flaky_tool"),
1780            crate::circuit_breaker::BreakerState::Open
1781        );
1782
1783        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1784        assert!(result.is_err());
1785        match result {
1786            Err(CoreError::CircuitBreaker(_)) => {}
1787            other => panic!("Expected CircuitBreaker error, got {other:?}"),
1788        }
1789    }
1790
1791    /// 2026-09-15 audit: a caller's malformed requests must not fast-fail the
1792    /// next valid request — only backend/execution failures count.
1793    #[tokio::test]
1794    async fn client_validation_errors_do_not_trip_the_breaker() {
1795        let breakers = Arc::new(CircuitBreakerRegistry::new(
1796            crate::circuit_breaker::BreakerConfig {
1797                failure_threshold: 3,
1798                window: std::time::Duration::from_secs(10),
1799                cooldown: std::time::Duration::from_secs(30),
1800            },
1801        ));
1802        let pipeline = DispatchPipeline::new(
1803            Arc::new(RateLimiter::new(10000, 100, 100)),
1804            breakers.clone(),
1805            Arc::new(DharmaGate::default()),
1806            None,
1807        );
1808        let mut ctx = Context::new(BrainWave::Gamma);
1809
1810        // Five invalid-galaxy-style caller errors: the shape that used to
1811        // trip the breaker and block the next correct call.
1812        let bad = TestTool::returning_error("validated_tool", || {
1813            CoreError::InvalidArgs("unknown galaxy".into())
1814        });
1815        for _ in 0..5 {
1816            let err = pipeline
1817                .dispatch(&bad, &mut ctx, Args::default())
1818                .await
1819                .unwrap_err();
1820            assert!(matches!(err, CoreError::InvalidArgs(_)));
1821        }
1822        assert_eq!(
1823            breakers.state("validated_tool"),
1824            crate::circuit_breaker::BreakerState::Closed,
1825            "caller errors must not open the breaker"
1826        );
1827
1828        // Governance refusals likewise stay caller/request-scoped.
1829        let governed = TestTool::returning_error("validated_tool", || {
1830            CoreError::Governance("budget exceeded for writes".into())
1831        });
1832        for _ in 0..5 {
1833            let _ = pipeline
1834                .dispatch(&governed, &mut ctx, Args::default())
1835                .await;
1836        }
1837        assert_eq!(
1838            breakers.state("validated_tool"),
1839            crate::circuit_breaker::BreakerState::Closed,
1840            "governance refusals must not open the breaker"
1841        );
1842
1843        // A healthy call still succeeds immediately.
1844        let good = TestTool::new("validated_tool", EffectRow::pure());
1845        pipeline
1846            .dispatch(&good, &mut ctx, Args::default())
1847            .await
1848            .expect("valid call after caller errors");
1849    }
1850
1851    #[tokio::test]
1852    async fn rate_limit_error_names_its_governor() {
1853        let pipeline = DispatchPipeline::new(
1854            Arc::new(RateLimiter::new(1000, 1, 0)),
1855            Arc::new(CircuitBreakerRegistry::default()),
1856            Arc::new(DharmaGate::default()),
1857            None,
1858        );
1859        let mut ctx = Context::new(BrainWave::Gamma);
1860        let tool = TestTool::new("bursty_tool", EffectRow::pure());
1861        pipeline
1862            .dispatch(&tool, &mut ctx, Args::default())
1863            .await
1864            .unwrap();
1865        let err = pipeline
1866            .dispatch(&tool, &mut ctx, Args::default())
1867            .await
1868            .unwrap_err();
1869        let text = err.to_string();
1870        assert!(
1871            text.contains("request rate limit") && text.contains("retry after"),
1872            "rate limit must name its category and retry hint: {text}"
1873        );
1874    }
1875
1876    #[tokio::test]
1877    async fn pipeline_karma_ledger_records() {
1878        let tmp = tempfile::tempdir().unwrap();
1879        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1880        let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1881
1882        let pipeline = DispatchPipeline::new(
1883            Arc::new(RateLimiter::default()),
1884            Arc::new(CircuitBreakerRegistry::default()),
1885            Arc::new(DharmaGate::default()),
1886            Some(ledger.clone()),
1887        );
1888
1889        let mut ctx = Context::new(BrainWave::Gamma);
1890        let tool = TestTool::new("karma_test_tool", EffectRow::pure());
1891
1892        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1893        assert!(result.is_ok());
1894        assert_eq!(ledger.next_id(), 1);
1895        assert_eq!(ctx.karma_debt, 0.0);
1896    }
1897
1898    #[tokio::test]
1899    async fn pipeline_karma_debt_updates_context() {
1900        let tmp = tempfile::tempdir().unwrap();
1901        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1902        let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1903
1904        let pipeline = DispatchPipeline::new(
1905            Arc::new(RateLimiter::default()),
1906            Arc::new(CircuitBreakerRegistry::default()),
1907            Arc::new(DharmaGate::default()),
1908            Some(ledger),
1909        );
1910
1911        let mut ctx = Context::new(BrainWave::Gamma);
1912        let tool = TestTool::new(
1913            "wasteful_tool",
1914            EffectRow {
1915                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1916                ..Default::default()
1917            },
1918        );
1919
1920        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1921        assert!(result.is_ok());
1922        assert!(
1923            (ctx.karma_debt - 0.2).abs() < 0.001,
1924            "Context karma_debt should be 0.2, got {}",
1925            ctx.karma_debt
1926        );
1927    }
1928
1929    #[tokio::test]
1930    async fn pipeline_karma_batched_e2e() {
1931        // E2E: Full dispatch cycle with batched karma writes produces
1932        // correct total_debt() and chain integrity after flush.
1933        let tmp = tempfile::tempdir().unwrap();
1934        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1935        let ledger = Arc::new(KarmaLedger::with_flush_threshold(store.clone(), 100).unwrap());
1936
1937        let pipeline = DispatchPipeline::new(
1938            Arc::new(RateLimiter::default()),
1939            Arc::new(CircuitBreakerRegistry::default()),
1940            Arc::new(DharmaGate::default()),
1941            Some(ledger.clone()),
1942        );
1943
1944        let mut ctx = Context::new(BrainWave::Gamma);
1945
1946        // Dispatch 10 honest tools (no debt) and 10 wasteful tools (0.2 debt each)
1947        let honest_tool = TestTool::new("honest_tool", EffectRow::pure());
1948        let wasteful_tool = TestTool::new(
1949            "wasteful_tool",
1950            EffectRow {
1951                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1952                ..Default::default()
1953            },
1954        );
1955
1956        for _ in 0..10 {
1957            let result = pipeline
1958                .dispatch(&honest_tool, &mut ctx, Args::default())
1959                .await;
1960            assert!(result.is_ok());
1961        }
1962        for _ in 0..10 {
1963            let result = pipeline
1964                .dispatch(&wasteful_tool, &mut ctx, Args::default())
1965                .await;
1966            assert!(result.is_ok());
1967        }
1968
1969        // 20 entries should be buffered (not yet in LMDB)
1970        assert_eq!(ledger.next_id(), 20);
1971        assert_eq!(
1972            ledger.pending_count(),
1973            20,
1974            "All 20 entries should be pending before flush"
1975        );
1976
1977        // total_debt() reads from in-memory chain state — should reflect all 20
1978        let debt = ledger.total_debt();
1979        assert!(
1980            (debt - 2.0).abs() < 0.001,
1981            "Total debt should be 2.0 (10 x 0.2), got {debt}"
1982        );
1983
1984        // Flush to persist all entries in one batch transaction
1985        ledger.flush().unwrap();
1986        assert_eq!(ledger.pending_count(), 0);
1987
1988        // Verify chain integrity after batched flush
1989        let result = ledger.verify_integrity().unwrap();
1990        assert!(
1991            result.valid,
1992            "Chain should be valid after batched flush: {:?}",
1993            result.violation
1994        );
1995        assert_eq!(result.entries_verified, 20);
1996
1997        // Verify entries are persisted by creating a new ledger from same store
1998        let ledger2 = KarmaLedger::new(store).unwrap();
1999        assert_eq!(
2000            ledger2.next_id(),
2001            20,
2002            "Next ID should persist across instances"
2003        );
2004        let entries = ledger2.scan_entries().unwrap();
2005        assert_eq!(
2006            entries.len(),
2007            20,
2008            "All 20 entries should be persisted in LMDB"
2009        );
2010
2011        // Verify total debt persisted
2012        let debt2 = ledger2.total_debt();
2013        assert!(
2014            (debt2 - 2.0).abs() < 0.001,
2015            "Total debt should persist as 2.0, got {debt2}"
2016        );
2017
2018        // Verify chain integrity on the reloaded ledger
2019        let result2 = ledger2.verify_integrity().unwrap();
2020        assert!(result2.valid, "Chain should be valid on reloaded ledger");
2021        assert_eq!(result2.entries_verified, 20);
2022    }
2023
2024    #[tokio::test]
2025    async fn pipeline_coherence_gate_blocks_writes() {
2026        let pipeline = DispatchPipeline::with_defaults();
2027        let mut ctx = Context::new(BrainWave::Gamma);
2028        ctx.citta_coherence = 0.1; // Below 0.3 threshold
2029        let tool = TestTool::new(
2030            "write_tool",
2031            EffectRow {
2032                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2033                ..Default::default()
2034            },
2035        );
2036
2037        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2038        assert!(result.is_err());
2039        match result {
2040            Err(CoreError::Governance(msg)) => {
2041                assert!(msg.contains("coherence"));
2042            }
2043            other => panic!("Expected Governance error, got {other:?}"),
2044        }
2045    }
2046
2047    #[tokio::test]
2048    async fn pipeline_coherence_gate_allows_reads() {
2049        let pipeline = DispatchPipeline::with_defaults();
2050        let mut ctx = Context::new(BrainWave::Gamma);
2051        ctx.citta_coherence = 0.1; // Below threshold, but no writes
2052        let tool = TestTool::new("read_tool", EffectRow::pure());
2053
2054        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2055        assert!(result.is_ok());
2056    }
2057
2058    #[tokio::test]
2059    async fn pipeline_coherence_gate_allows_writes_when_coherent() {
2060        let pipeline = DispatchPipeline::with_defaults();
2061        let mut ctx = Context::new(BrainWave::Gamma);
2062        ctx.citta_coherence = 0.5; // Above threshold
2063        let tool = TestTool::new(
2064            "write_tool",
2065            EffectRow {
2066                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2067                ..Default::default()
2068            },
2069        );
2070
2071        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2072        assert!(result.is_ok());
2073    }
2074
2075    #[tokio::test]
2076    async fn pipeline_low_confidence_blocks_writes() {
2077        let pipeline = DispatchPipeline::with_defaults();
2078        let mut ctx = Context::new(BrainWave::Gamma);
2079        ctx.self_model_confidence = 0.3; // Below 0.5 threshold
2080        let tool = TestTool::new(
2081            "write_tool",
2082            EffectRow {
2083                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2084                ..Default::default()
2085            },
2086        );
2087
2088        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2089        assert!(result.is_err());
2090        match result {
2091            Err(CoreError::Governance(msg)) => {
2092                assert!(msg.contains("confidence"));
2093                assert!(msg.contains("conservative"));
2094            }
2095            other => panic!("Expected Governance error, got {other:?}"),
2096        }
2097    }
2098
2099    #[tokio::test]
2100    async fn pipeline_low_confidence_allows_reads() {
2101        let pipeline = DispatchPipeline::with_defaults();
2102        let mut ctx = Context::new(BrainWave::Gamma);
2103        ctx.self_model_confidence = 0.3; // Below threshold, but no writes
2104        let tool = TestTool::new("read_tool", EffectRow::pure());
2105
2106        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2107        assert!(result.is_ok());
2108    }
2109
2110    #[tokio::test]
2111    async fn pipeline_high_confidence_allows_writes() {
2112        let pipeline = DispatchPipeline::with_defaults();
2113        let mut ctx = Context::new(BrainWave::Gamma);
2114        ctx.self_model_confidence = 0.8; // Above threshold
2115        let tool = TestTool::new(
2116            "write_tool",
2117            EffectRow {
2118                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2119                ..Default::default()
2120            },
2121        );
2122
2123        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2124        assert!(result.is_ok());
2125    }
2126
2127    #[tokio::test]
2128    async fn pipeline_high_caution_warns_on_writes() {
2129        let pipeline = DispatchPipeline::with_defaults();
2130        let mut ctx = Context::new(BrainWave::Gamma);
2131        ctx.drive_caution = 0.9; // Above 0.85 threshold
2132        let tool = TestTool::new(
2133            "write_tool",
2134            EffectRow {
2135                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2136                ..Default::default()
2137            },
2138        );
2139
2140        // Should still succeed — caution is a warning, not a block
2141        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2142        assert!(result.is_ok());
2143    }
2144
2145    #[tokio::test]
2146    async fn pipeline_low_energy_warns_on_writes() {
2147        let pipeline = DispatchPipeline::with_defaults();
2148        let mut ctx = Context::new(BrainWave::Gamma);
2149        ctx.drive_energy = 0.1; // Below 0.15 threshold
2150        let tool = TestTool::new(
2151            "write_tool",
2152            EffectRow {
2153                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2154                ..Default::default()
2155            },
2156        );
2157
2158        // Should still succeed — low energy is a warning, not a block
2159        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2160        assert!(result.is_ok());
2161    }
2162
2163    #[tokio::test]
2164    async fn pipeline_drive_gates_dont_affect_reads() {
2165        let pipeline = DispatchPipeline::with_defaults();
2166        let mut ctx = Context::new(BrainWave::Gamma);
2167        ctx.drive_caution = 0.95;
2168        ctx.drive_energy = 0.05;
2169        let tool = TestTool::new("read_tool", EffectRow::pure());
2170
2171        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2172        assert!(result.is_ok());
2173    }
2174
2175    #[tokio::test]
2176    async fn pipeline_destructive_blocked_without_confirm() {
2177        let pipeline = DispatchPipeline::with_defaults();
2178        let mut ctx = Context::new(BrainWave::Gamma);
2179        let tool = TestTool::new(
2180            "destructive_tool",
2181            EffectRow {
2182                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2183                destructive: true,
2184                ..Default::default()
2185            },
2186        );
2187
2188        let result = pipeline
2189            .dispatch(&tool, &mut ctx, serde_json::json!({}))
2190            .await;
2191        assert!(result.is_err());
2192        match result {
2193            Err(CoreError::Governance(msg)) => {
2194                assert!(msg.contains("destructive"));
2195                assert!(msg.contains("confirm"));
2196            }
2197            other => panic!("Expected Governance error, got {other:?}"),
2198        }
2199    }
2200
2201    #[tokio::test]
2202    async fn pipeline_destructive_allowed_with_confirm() {
2203        let pipeline = DispatchPipeline::with_defaults();
2204        let mut ctx = Context::new(BrainWave::Gamma);
2205        let tool = TestTool::new(
2206            "destructive_tool",
2207            EffectRow {
2208                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2209                destructive: true,
2210                ..Default::default()
2211            },
2212        );
2213
2214        let result = pipeline
2215            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
2216            .await;
2217        assert!(result.is_ok());
2218    }
2219
2220    #[tokio::test]
2221    async fn pipeline_destructive_blocked_with_false_confirm() {
2222        let pipeline = DispatchPipeline::with_defaults();
2223        let mut ctx = Context::new(BrainWave::Gamma);
2224        let tool = TestTool::new(
2225            "destructive_tool",
2226            EffectRow {
2227                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2228                destructive: true,
2229                ..Default::default()
2230            },
2231        );
2232
2233        let result = pipeline
2234            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": false}))
2235            .await;
2236        assert!(result.is_err());
2237    }
2238
2239    #[tokio::test]
2240    async fn pipeline_compartment_no_restriction_allows_all() {
2241        let pipeline = DispatchPipeline::with_defaults();
2242        let mut ctx = Context::new(BrainWave::Gamma);
2243        // No compartment set — full access
2244        let tool = TestTool::new(
2245            "write_tool",
2246            EffectRow {
2247                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2248                ..Default::default()
2249            },
2250        );
2251
2252        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2253        assert!(result.is_ok());
2254    }
2255
2256    #[tokio::test]
2257    async fn pipeline_compartment_sandbox_blocks_write_to_codex() {
2258        let pipeline = DispatchPipeline::with_defaults();
2259        let mut ctx = Context::new(BrainWave::Gamma);
2260        ctx.compartment = Some("sandbox".into());
2261        let tool = TestTool::new(
2262            "write_tool",
2263            EffectRow {
2264                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2265                ..Default::default()
2266            },
2267        );
2268
2269        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2270        assert!(result.is_err());
2271        match result {
2272            Err(CoreError::Governance(msg)) => {
2273                assert!(msg.contains("sandbox"));
2274                assert!(msg.contains("codex"));
2275            }
2276            other => panic!("Expected Governance error, got {other:?}"),
2277        }
2278    }
2279
2280    #[tokio::test]
2281    async fn pipeline_asserted_user_id_confers_no_authority() {
2282        // P-DEPUTY-2 (2026-09-10, Glama confused-deputy series): the
2283        // client-asserted `_meta.user_id` label is attribution only — it
2284        // must never widen compartment authority. A sandbox dispatch
2285        // labeled as any privileged user is still a sandbox dispatch.
2286        let pipeline = DispatchPipeline::with_defaults();
2287        let mut ctx = Context::new(BrainWave::Gamma);
2288        ctx.compartment = Some("sandbox".into());
2289        ctx.user_id = Some("ceo".into());
2290        let tool = TestTool::new(
2291            "write_tool",
2292            EffectRow {
2293                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2294                ..Default::default()
2295            },
2296        );
2297
2298        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2299        assert!(result.is_err());
2300        match result {
2301            Err(CoreError::Governance(msg)) => {
2302                assert!(msg.contains("sandbox"));
2303                assert!(msg.contains("codex"));
2304            }
2305            other => panic!("Expected Governance error, got {other:?}"),
2306        }
2307    }
2308
2309    #[tokio::test]
2310    async fn pipeline_routes_store_scoped_tools_through_executor() {
2311        // P-SANDBOX-3 (Landlock v1): `StoreScoped` marks route through the
2312        // executor when one is attached; plain tools keep the ambient path.
2313        use crate::sandbox_exec::ScopedSandboxExecutor;
2314        use std::sync::atomic::{AtomicU64, Ordering};
2315        let calls = Arc::new(AtomicU64::new(0));
2316        let counter = Arc::clone(&calls);
2317        let executor = Arc::new(ScopedSandboxExecutor::new(move || {
2318            counter.fetch_add(1, Ordering::SeqCst);
2319            Ok(())
2320        }));
2321        let pipeline =
2322            DispatchPipeline::with_defaults().with_sandbox_executor(Some(Arc::clone(&executor)));
2323        let mut ctx = Context::new(BrainWave::Gamma);
2324
2325        let scoped = TestTool::new(
2326            "scoped_tool",
2327            EffectRow {
2328                sandbox: Sandbox::StoreScoped,
2329                ..Default::default()
2330            },
2331        );
2332        assert!(
2333            pipeline
2334                .dispatch(&scoped, &mut ctx, Args::default())
2335                .await
2336                .is_ok()
2337        );
2338        assert_eq!(calls.load(Ordering::SeqCst), 1, "scoped tool must confine");
2339
2340        let plain = TestTool::new("plain_tool", EffectRow::pure());
2341        assert!(
2342            pipeline
2343                .dispatch(&plain, &mut ctx, Args::default())
2344                .await
2345                .is_ok()
2346        );
2347        assert_eq!(
2348            calls.load(Ordering::SeqCst),
2349            1,
2350            "plain tools must not ride the sandbox path"
2351        );
2352        assert_eq!(executor.stats(), (1, 0, 0));
2353
2354        // A scoped tool with no executor attached is inert (v0 behavior).
2355        let bare = DispatchPipeline::with_defaults();
2356        let scoped2 = TestTool::new(
2357            "scoped_tool",
2358            EffectRow {
2359                sandbox: Sandbox::StoreScoped,
2360                ..Default::default()
2361            },
2362        );
2363        assert!(
2364            bare.dispatch(&scoped2, &mut ctx, Args::default())
2365                .await
2366                .is_ok()
2367        );
2368    }
2369
2370    #[tokio::test]
2371    async fn pipeline_injects_subprocess_policy_and_discloses() {
2372        // B2: declared `Sandbox::Subprocess` tools get a runner-backed
2373        // policy on their context before the call, and the active runner is
2374        // disclosed on the response.
2375        use crate::subprocess_sandbox::SubprocessSandbox;
2376        use std::path::PathBuf;
2377        use wm_core::sandbox::RunnerSource;
2378        let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2379            wm_core::sandbox::RunnerInfo {
2380                path: PathBuf::from("/opt/mandala-sandbox"),
2381                source: RunnerSource::Env,
2382            },
2383        )));
2384        let pipeline =
2385            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2386        let mut ctx = Context::new(BrainWave::Gamma);
2387        let tool = TestTool::new(
2388            "spawn_tool",
2389            EffectRow {
2390                reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2391                spawns: true,
2392                sandbox: Sandbox::Subprocess,
2393                ..Default::default()
2394            },
2395        )
2396        .with_output(serde_json::json!({"ok": true}));
2397
2398        let out = pipeline
2399            .dispatch(&tool, &mut ctx, Args::default())
2400            .await
2401            .expect("declared spawn tool dispatches");
2402        assert!(ctx.spawn.is_active(), "policy must ride the context");
2403        assert!(ctx.spawn.allow_net(), "network read grants the runner net");
2404        assert_eq!(out["sandbox"]["runner"], "/opt/mandala-sandbox");
2405        assert_eq!(out["sandbox"]["net"], true);
2406        assert_eq!(
2407            out["sandbox"]["envelope"],
2408            wm_core::sandbox::ENVELOPE_SCHEMA
2409        );
2410        assert_eq!(sandbox.status()["dispatches"], 1);
2411        assert_eq!(sandbox.status()["degraded"], 0);
2412    }
2413
2414    #[tokio::test]
2415    async fn pipeline_degrades_loudly_when_runner_missing() {
2416        // No runner resolvable: the declared tool still runs (availability
2417        // first), the dispatch is counted, and no confinement is claimed.
2418        use crate::subprocess_sandbox::SubprocessSandbox;
2419        let sandbox = Arc::new(SubprocessSandbox::with_runner(None));
2420        let pipeline =
2421            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2422        let mut ctx = Context::new(BrainWave::Gamma);
2423        let tool = TestTool::new(
2424            "spawn_tool",
2425            EffectRow {
2426                reads: vec![wm_core::Resource::Process],
2427                spawns: true,
2428                sandbox: Sandbox::Subprocess,
2429                ..Default::default()
2430            },
2431        )
2432        .with_output(serde_json::json!({"ok": true}));
2433
2434        let out = pipeline
2435            .dispatch(&tool, &mut ctx, Args::default())
2436            .await
2437            .expect("degrade keeps availability up");
2438        assert!(!ctx.spawn.is_active());
2439        assert!(
2440            out.get("sandbox").is_none(),
2441            "no runner means no confinement claim"
2442        );
2443        assert_eq!(sandbox.status()["dispatches"], 1);
2444        assert_eq!(sandbox.status()["degraded"], 1);
2445    }
2446
2447    #[tokio::test]
2448    async fn pipeline_surfaces_unmigrated_spawn_tools() {
2449        // A tool that declares raw `spawns` without adopting the
2450        // `Sandbox::Subprocess` contract is counted and warned — the seam
2451        // must not silently pretend coverage it does not have.
2452        use crate::subprocess_sandbox::SubprocessSandbox;
2453        use std::path::PathBuf;
2454        use wm_core::sandbox::RunnerSource;
2455        let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2456            wm_core::sandbox::RunnerInfo {
2457                path: PathBuf::from("/opt/mandala-sandbox"),
2458                source: RunnerSource::Env,
2459            },
2460        )));
2461        let pipeline =
2462            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2463        let mut ctx = Context::new(BrainWave::Gamma);
2464        let tool = TestTool::new(
2465            "legacy_git_tool",
2466            EffectRow {
2467                reads: vec![wm_core::Resource::Process],
2468                spawns: true,
2469                ..Default::default()
2470            },
2471        );
2472
2473        assert!(
2474            pipeline
2475                .dispatch(&tool, &mut ctx, Args::default())
2476                .await
2477                .is_ok()
2478        );
2479        assert!(!ctx.spawn.is_active());
2480        assert_eq!(sandbox.status()["unconfined_spawns"], 1);
2481        assert_eq!(sandbox.status()["dispatches"], 0);
2482    }
2483
2484    #[cfg(unix)]
2485    #[tokio::test]
2486    async fn declared_spawn_executes_through_the_runner_envelope() {
2487        // End-to-end wrap proof: a tool builds its command through
2488        // `ctx.spawn.command(...)`, the fake runner receives the JSON
2489        // envelope on argv, and the envelope carries program/args/net.
2490        use crate::subprocess_sandbox::SubprocessSandbox;
2491        use std::os::unix::fs::PermissionsExt;
2492        use wm_core::sandbox::{RunnerInfo, RunnerSource};
2493
2494        let dir = tempfile::tempdir().expect("tempdir");
2495        let marker = dir.path().join("envelope.json");
2496        let runner = dir.path().join("fake-runner");
2497        std::fs::write(
2498            &runner,
2499            format!(
2500                "#!/bin/sh\nprintf '%s' \"$2\" > '{}'\nexit 0\n",
2501                marker.display()
2502            ),
2503        )
2504        .expect("write fake runner");
2505        std::fs::set_permissions(&runner, std::fs::Permissions::from_mode(0o755))
2506            .expect("chmod fake runner");
2507
2508        struct SpawnProbeTool {
2509            effects: EffectRow,
2510            stats: ToolStats,
2511        }
2512        #[async_trait]
2513        impl Tool for SpawnProbeTool {
2514            fn name(&self) -> &str {
2515                "spawn_probe"
2516            }
2517            fn gana(&self) -> Gana {
2518                Gana::Heart
2519            }
2520            fn effects(&self) -> &EffectRow {
2521                &self.effects
2522            }
2523            async fn call(&self, ctx: &mut Context, _args: Args) -> Result<Output> {
2524                let out = ctx
2525                    .spawn
2526                    .command("printf", &["%s", "hi"])
2527                    .output()
2528                    .map_err(|e| CoreError::Tool(format!("spawn failed: {e}")))?;
2529                if !out.status.success() {
2530                    return Err(CoreError::Tool("wrapped command failed".into()));
2531                }
2532                Ok(serde_json::json!({"ok": true}))
2533            }
2534            fn stats(&self) -> &ToolStats {
2535                &self.stats
2536            }
2537        }
2538
2539        let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(RunnerInfo {
2540            path: runner,
2541            source: RunnerSource::Env,
2542        })));
2543        let pipeline =
2544            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2545        let mut ctx = Context::new(BrainWave::Gamma);
2546        let tool = SpawnProbeTool {
2547            effects: EffectRow {
2548                reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2549                spawns: true,
2550                sandbox: Sandbox::Subprocess,
2551                ..Default::default()
2552            },
2553            stats: ToolStats::default(),
2554        };
2555        let out = pipeline
2556            .dispatch(&tool, &mut ctx, Args::default())
2557            .await
2558            .expect("wrapped spawn succeeds");
2559        assert_eq!(out["ok"], true);
2560        assert_eq!(out["sandbox"]["net"], true);
2561
2562        let captured = std::fs::read_to_string(&marker).expect("runner captured the envelope");
2563        let envelope: serde_json::Value = serde_json::from_str(&captured).expect("envelope JSON");
2564        assert_eq!(envelope["schema"], wm_core::sandbox::ENVELOPE_SCHEMA);
2565        assert_eq!(envelope["program"], "printf");
2566        assert_eq!(envelope["args"], serde_json::json!(["%s", "hi"]));
2567        assert_eq!(envelope["net"], true);
2568    }
2569
2570    #[tokio::test]
2571    async fn pipeline_secret_scan_warns_without_blocking() {
2572        // P-PROV-5/B(c): the output sampler observes but never governs.
2573        // A credential-shaped successful output dispatches fine and
2574        // records exactly one hit on the attached sampler.
2575        use crate::secret_scan::SecretSampler;
2576        let sampler = Arc::new(SecretSampler::new(1));
2577        let pipeline =
2578            DispatchPipeline::with_defaults().with_secret_scan_option(Some(Arc::clone(&sampler)));
2579        let mut ctx = Context::new(BrainWave::Gamma);
2580        let tool = TestTool::new("key_tool", EffectRow::pure())
2581            .with_output(serde_json::json!({"data": "key=AKIAIOSFODNN7EXAMPLE"}));
2582        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2583        assert!(result.is_ok(), "warn-only scan must never block");
2584        assert_eq!(sampler.stats(), (1, 1, 1));
2585
2586        // Clean outputs scan without hits.
2587        let clean = TestTool::new("clean_tool", EffectRow::pure())
2588            .with_output(serde_json::json!({"results": []}));
2589        assert!(
2590            pipeline
2591                .dispatch(&clean, &mut ctx, Args::default())
2592                .await
2593                .is_ok()
2594        );
2595        assert_eq!(sampler.stats(), (2, 2, 1));
2596    }
2597
2598    #[tokio::test]
2599    async fn pipeline_compartment_sandbox_blocks_read_from_karma() {
2600        let pipeline = DispatchPipeline::with_defaults();
2601        let mut ctx = Context::new(BrainWave::Gamma);
2602        ctx.compartment = Some("sandbox".into());
2603        let tool = TestTool::new(
2604            "read_tool",
2605            EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2606        );
2607
2608        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2609        assert!(result.is_err());
2610        match result {
2611            Err(CoreError::Governance(msg)) => {
2612                assert!(msg.contains("sandbox"));
2613                assert!(msg.contains("karma"));
2614            }
2615            other => panic!("Expected Governance error, got {other:?}"),
2616        }
2617    }
2618
2619    #[tokio::test]
2620    async fn pipeline_compartment_sandbox_allows_write_to_tutorial() {
2621        let pipeline = DispatchPipeline::with_defaults();
2622        let mut ctx = Context::new(BrainWave::Gamma);
2623        ctx.compartment = Some("sandbox".into());
2624        let tool = TestTool::new(
2625            "write_tool",
2626            EffectRow {
2627                writes: vec![wm_core::Resource::Galaxy("tutorial".into())],
2628                ..Default::default()
2629            },
2630        );
2631
2632        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2633        assert!(result.is_ok());
2634    }
2635
2636    #[tokio::test]
2637    async fn pipeline_compartment_sandbox_allows_read_from_research() {
2638        let pipeline = DispatchPipeline::with_defaults();
2639        let mut ctx = Context::new(BrainWave::Gamma);
2640        ctx.compartment = Some("sandbox".into());
2641        let tool = TestTool::new(
2642            "read_tool",
2643            EffectRow::read_only(vec![wm_core::Resource::Galaxy("research".into())]),
2644        );
2645
2646        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2647        assert!(result.is_ok());
2648    }
2649
2650    #[tokio::test]
2651    async fn pipeline_compartment_production_blocks_read_from_karma() {
2652        let pipeline = DispatchPipeline::with_defaults();
2653        let mut ctx = Context::new(BrainWave::Gamma);
2654        ctx.compartment = Some("production".into());
2655        let tool = TestTool::new(
2656            "read_tool",
2657            EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2658        );
2659
2660        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2661        assert!(result.is_err());
2662        match result {
2663            Err(CoreError::Governance(msg)) => {
2664                assert!(msg.contains("production"));
2665                assert!(msg.contains("karma"));
2666            }
2667            other => panic!("Expected Governance error, got {other:?}"),
2668        }
2669    }
2670
2671    #[tokio::test]
2672    async fn pipeline_compartment_production_allows_write_to_codex() {
2673        let pipeline = DispatchPipeline::with_defaults();
2674        let mut ctx = Context::new(BrainWave::Gamma);
2675        ctx.compartment = Some("production".into());
2676        let tool = TestTool::new(
2677            "write_tool",
2678            EffectRow {
2679                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2680                ..Default::default()
2681            },
2682        );
2683
2684        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2685        assert!(result.is_ok());
2686    }
2687
2688    #[tokio::test]
2689    async fn pipeline_compartment_secure_allows_write_to_codex() {
2690        let pipeline = DispatchPipeline::with_defaults();
2691        let mut ctx = Context::new(BrainWave::Gamma);
2692        ctx.compartment = Some("secure".into());
2693        let tool = TestTool::new(
2694            "write_tool",
2695            EffectRow {
2696                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2697                ..Default::default()
2698            },
2699        );
2700
2701        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2702        assert!(result.is_ok());
2703    }
2704
2705    #[tokio::test]
2706    async fn pipeline_compartment_secure_blocks_read_from_karma() {
2707        let pipeline = DispatchPipeline::with_defaults();
2708        let mut ctx = Context::new(BrainWave::Gamma);
2709        ctx.compartment = Some("secure".into());
2710        let tool = TestTool::new(
2711            "read_tool",
2712            EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2713        );
2714
2715        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2716        assert!(result.is_err());
2717        match result {
2718            Err(CoreError::Governance(msg)) => {
2719                assert!(msg.contains("secure"));
2720                assert!(msg.contains("karma"));
2721            }
2722            other => panic!("Expected Governance error, got {other:?}"),
2723        }
2724    }
2725
2726    // ── Resource rules (Yama) pipeline tests ──────────────────────────
2727
2728    fn rules_with(max_writes: u32, max_repeats: u32) -> Arc<ResourceRules> {
2729        Arc::new(ResourceRules::new(ResourceRulesConfig {
2730            max_writes_per_minute: max_writes,
2731            max_spawns_per_minute: 100,
2732            max_network_per_minute: 100,
2733            novelty_window: 50,
2734            max_repeats,
2735            require_human_review: false,
2736        }))
2737    }
2738
2739    #[tokio::test]
2740    async fn pipeline_resource_rules_budget_exceeding_write_refused() {
2741        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(2, 1000));
2742        let mut ctx = Context::new(BrainWave::Gamma);
2743        let tool = TestTool::new(
2744            "write_tool",
2745            EffectRow {
2746                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2747                ..Default::default()
2748            },
2749        );
2750
2751        assert!(
2752            pipeline
2753                .dispatch(&tool, &mut ctx, Args::default())
2754                .await
2755                .is_ok(),
2756            "first write within budget"
2757        );
2758        assert!(
2759            pipeline
2760                .dispatch(&tool, &mut ctx, Args::default())
2761                .await
2762                .is_ok(),
2763            "second write within budget"
2764        );
2765        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2766        assert!(result.is_err(), "third write must exceed the budget");
2767        match result {
2768            Err(CoreError::Governance(msg)) => {
2769                assert!(msg.contains("resource rules"), "got: {msg}");
2770                assert!(msg.contains("writes"), "got: {msg}");
2771            }
2772            other => panic!("Expected Governance error, got {other:?}"),
2773        }
2774    }
2775
2776    #[tokio::test]
2777    async fn pipeline_resource_rules_novelty_flag_reaches_response() {
2778        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(1000, 1));
2779        let mut ctx = Context::new(BrainWave::Gamma);
2780        let tool = TestTool::new("read_tool", EffectRow::pure())
2781            .with_output(serde_json::json!({"status": "ok"}));
2782
2783        let first = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2784        assert!(first.is_ok());
2785        assert!(
2786            first.unwrap().get("resource_flags").is_none(),
2787            "first call is novel — no flag"
2788        );
2789
2790        let second = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2791        let output = second.expect("repeated call must still succeed (flag, not block)");
2792        let flags = output
2793            .get("resource_flags")
2794            .and_then(|f| f.as_array())
2795            .expect("novelty flag must reach the response");
2796        assert_eq!(flags.len(), 1);
2797        assert!(flags[0].as_str().unwrap().contains("not novel"));
2798    }
2799
2800    #[tokio::test]
2801    async fn pipeline_resource_rules_blocks_unapproved_autonomous() {
2802        let rules = Arc::new(ResourceRules::default());
2803        rules.set_user_initiated(false);
2804        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2805        let mut ctx = Context::new(BrainWave::Gamma);
2806        let tool = TestTool::new(
2807            "memory.consolidate",
2808            EffectRow {
2809                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2810                ..Default::default()
2811            },
2812        );
2813
2814        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2815        assert!(result.is_err());
2816        match result {
2817            Err(CoreError::Governance(msg)) => {
2818                assert!(msg.contains("human review"), "got: {msg}");
2819            }
2820            other => panic!("Expected Governance error, got {other:?}"),
2821        }
2822    }
2823
2824    #[tokio::test]
2825    async fn pipeline_resource_rules_allows_approved_autonomous() {
2826        let rules = Arc::new(ResourceRules::default());
2827        rules.set_user_initiated(false);
2828        rules.set_human_approved(true);
2829        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2830        let mut ctx = Context::new(BrainWave::Gamma);
2831        let tool = TestTool::new(
2832            "memory.consolidate",
2833            EffectRow {
2834                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2835                ..Default::default()
2836            },
2837        );
2838
2839        let result = pipeline
2840            .dispatch(
2841                &tool,
2842                &mut ctx,
2843                serde_json::json!({"purpose": "consolidate codex"}),
2844            )
2845            .await;
2846        assert!(result.is_ok());
2847    }
2848
2849    #[tokio::test]
2850    async fn pipeline_resource_rules_user_initiated_writes_allowed_by_default() {
2851        // Default rules: user-initiated actions are not gated by human review.
2852        let pipeline = DispatchPipeline::with_defaults()
2853            .with_resource_rules(Arc::new(ResourceRules::default()));
2854        let mut ctx = Context::new(BrainWave::Gamma);
2855        let tool = TestTool::new(
2856            "write_tool",
2857            EffectRow {
2858                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2859                ..Default::default()
2860            },
2861        );
2862
2863        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2864        assert!(result.is_ok());
2865    }
2866
2867    // ── Runtime Satya (fabrication) tests ─────────────────────────────
2868
2869    #[tokio::test]
2870    async fn pipeline_runtime_satya_blocks_citta_write_without_read() {
2871        let pipeline = DispatchPipeline::with_defaults();
2872        let mut ctx = Context::new(BrainWave::Gamma);
2873        let tool = TestTool::new(
2874            "memory.create",
2875            EffectRow {
2876                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2877                ..Default::default()
2878            },
2879        );
2880
2881        let result = pipeline
2882            .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2883            .await;
2884        assert!(result.is_err());
2885        match result {
2886            Err(CoreError::Governance(msg)) => {
2887                assert!(msg.contains("VIOLATION_SATYA"), "got: {msg}");
2888            }
2889            other => panic!("Expected Governance error, got {other:?}"),
2890        }
2891    }
2892
2893    #[tokio::test]
2894    async fn pipeline_runtime_satya_allows_citta_write_with_read_evidence() {
2895        let pipeline = DispatchPipeline::with_defaults();
2896        let mut ctx = Context::new(BrainWave::Gamma);
2897        let tool = TestTool::new(
2898            "consolidate_tool",
2899            EffectRow {
2900                reads: vec![wm_core::Resource::Galaxy("citta".into())],
2901                writes: vec![wm_core::Resource::Galaxy("citta".into())],
2902                ..Default::default()
2903            },
2904        );
2905
2906        let result = pipeline
2907            .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2908            .await;
2909        assert!(result.is_ok());
2910    }
2911
2912    #[tokio::test]
2913    async fn pipeline_runtime_satya_allows_non_citta_runtime_galaxy() {
2914        let pipeline = DispatchPipeline::with_defaults();
2915        let mut ctx = Context::new(BrainWave::Gamma);
2916        let tool = TestTool::new(
2917            "memory.create",
2918            EffectRow {
2919                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2920                ..Default::default()
2921            },
2922        );
2923
2924        let result = pipeline
2925            .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "research"}))
2926            .await;
2927        assert!(result.is_ok());
2928    }
2929
2930    // ── Write-audit journal pipeline tests ────────────────────────────
2931
2932    #[tokio::test]
2933    async fn pipeline_write_audit_detects_misdeclaring_tool() {
2934        let tmp = tempfile::tempdir().unwrap();
2935        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2936        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2937        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2938        let mut ctx = Context::new(BrainWave::Gamma);
2939
2940        // Declares a pure effect row but actually writes to the store.
2941        let tool = TestTool::new("sneaky_tool", EffectRow::pure()).with_store(store);
2942
2943        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2944        assert!(result.is_ok());
2945
2946        let mis = journal.misdeclarations().unwrap();
2947        assert!(!mis.is_empty(), "misdeclaring tool must be detected");
2948        assert_eq!(mis.last().unwrap().tool, "sneaky_tool");
2949        assert!(mis.last().unwrap().undeclared_mutation());
2950    }
2951
2952    #[tokio::test]
2953    async fn pipeline_write_audit_skips_meta_router() {
2954        let tmp = tempfile::tempdir().unwrap();
2955        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2956        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2957        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2958        let mut ctx = Context::new(BrainWave::Gamma);
2959
2960        // The meta-router mutates through nested dispatches (which journal
2961        // the real tool); its own entry must never be flagged as an
2962        // undeclared mutation (first-run feedback regression, 2026-09-13).
2963        let tool = TestTool::new("wm", EffectRow::pure()).with_store(store);
2964        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2965        assert!(result.is_ok());
2966
2967        let mis = journal.misdeclarations().unwrap();
2968        assert!(
2969            mis.iter().all(|m| m.tool != "wm"),
2970            "meta router must not appear as a misdeclaration: {mis:?}"
2971        );
2972    }
2973
2974    #[tokio::test]
2975    async fn pipeline_write_audit_records_declared_writes_with_identity() {
2976        let tmp = tempfile::tempdir().unwrap();
2977        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2978        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2979        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2980        let mut ctx = Context::new(BrainWave::Gamma);
2981
2982        let tool = TestTool::new(
2983            "honest_tool",
2984            EffectRow {
2985                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2986                ..Default::default()
2987            },
2988        )
2989        .with_store(store);
2990
2991        let args = serde_json::json!({"id": "abc-123", "content_hash": "hash-xyz"});
2992        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2993        assert!(result.is_ok());
2994
2995        let entries = journal.scan_entries().unwrap();
2996        assert_eq!(entries.len(), 1);
2997        let entry = &entries[0];
2998        assert!(entry.declared_writes);
2999        assert!(entry.store_write_delta >= 1);
3000        assert_eq!(entry.memory_id.as_deref(), Some("abc-123"));
3001        assert_eq!(entry.content_hash.as_deref(), Some("hash-xyz"));
3002        assert!(journal.misdeclarations().unwrap().is_empty());
3003    }
3004
3005    #[tokio::test]
3006    async fn pipeline_write_audit_captures_actor_identity() {
3007        // S11b: the journal answers "which agent did this" — identity rides
3008        // the Context (_meta-derived) into every entry.
3009        let tmp = tempfile::tempdir().unwrap();
3010        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3011        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3012        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3013        let mut ctx = Context::new(BrainWave::Gamma);
3014        ctx.session_id = Some(uuid::Uuid::nil());
3015        ctx.user_id = Some("agent-b".to_string());
3016        ctx.compartment = Some("production".to_string());
3017
3018        let tool = TestTool::new(
3019            "honest_tool",
3020            EffectRow {
3021                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3022                ..Default::default()
3023            },
3024        )
3025        .with_store(store);
3026
3027        let result = pipeline
3028            .dispatch(&tool, &mut ctx, serde_json::json!({"id": "abc-123"}))
3029            .await;
3030        assert!(result.is_ok());
3031
3032        let entries = journal.scan_entries().unwrap();
3033        assert_eq!(entries.len(), 1);
3034        let entry = &entries[0];
3035        assert_eq!(
3036            entry.actor_session.as_deref(),
3037            Some(uuid::Uuid::nil().to_string().as_str())
3038        );
3039        assert_eq!(entry.actor_user.as_deref(), Some("agent-b"));
3040        assert_eq!(entry.actor_compartment.as_deref(), Some("production"));
3041    }
3042
3043    #[tokio::test]
3044    async fn pipeline_write_audit_read_dispatch_not_flagged_after_external_writes() {
3045        // The 2026-08-28 restore-drill false positive: a parallel session's
3046        // writes land before (or while) an honest read-only dispatch runs;
3047        // the old since-last-entry attribution flagged the read tool with
3048        // the other dispatch's write count. Per-dispatch baselines close it.
3049        let tmp = tempfile::tempdir().unwrap();
3050        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3051        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3052        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3053        let mut ctx = Context::new(BrainWave::Gamma);
3054
3055        // The other session's traffic lands before this dispatch starts.
3056        for i in 0..3 {
3057            let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("other session {i}"));
3058            store.put(wm_core::Galaxy::Codex, &mem).unwrap();
3059        }
3060
3061        let read_tool = TestTool::new("memory.search", EffectRow::pure());
3062        let result = pipeline
3063            .dispatch(&read_tool, &mut ctx, Args::default())
3064            .await;
3065        assert!(result.is_ok());
3066
3067        let mis = journal.misdeclarations().unwrap();
3068        assert!(
3069            mis.is_empty(),
3070            "read-only dispatch must not inherit the other session's writes: {mis:?}"
3071        );
3072        let entries = journal.scan_entries().unwrap();
3073        assert_eq!(entries.last().unwrap().store_write_delta, 0);
3074    }
3075
3076    // ── Firebreak (P1.4 forbidden-command veto + P1.6 bulk-scope law) ──
3077
3078    #[tokio::test]
3079    async fn pipeline_firebreak_forbidden_blocks_even_with_confirm() {
3080        let pipeline = DispatchPipeline::with_defaults();
3081        let mut ctx = Context::new(BrainWave::Gamma);
3082        let tool = TestTool::new(
3083            "destructive_tool",
3084            EffectRow {
3085                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3086                destructive: true,
3087                ..Default::default()
3088            },
3089        );
3090
3091        let result = pipeline
3092            .dispatch(
3093                &tool,
3094                &mut ctx,
3095                serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
3096            )
3097            .await;
3098        match result {
3099            Err(CoreError::Governance(msg)) => {
3100                assert!(msg.contains("FORBIDDEN"), "got: {msg}");
3101                assert!(msg.contains("never allowed"), "got: {msg}");
3102            }
3103            other => panic!("Expected Governance error, got {other:?}"),
3104        }
3105    }
3106
3107    #[tokio::test]
3108    async fn pipeline_firebreak_scope_law_blocks_unscoped_destructive() {
3109        let pipeline = DispatchPipeline::with_defaults();
3110        let mut ctx = Context::new(BrainWave::Gamma);
3111        // Named like the real tool so the scope registry entry applies.
3112        let tool = TestTool::new(
3113            "memory.delete",
3114            EffectRow {
3115                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3116                destructive: true,
3117                ..Default::default()
3118            },
3119        );
3120
3121        let result = pipeline
3122            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
3123            .await;
3124        match result {
3125            Err(CoreError::Governance(msg)) => {
3126                assert!(msg.contains("no explicit scope"), "got: {msg}");
3127                assert!(msg.contains("id"), "names the scope field: {msg}");
3128            }
3129            other => panic!("Expected Governance error, got {other:?}"),
3130        }
3131    }
3132
3133    #[tokio::test]
3134    async fn pipeline_firebreak_scope_law_allows_scoped_destructive() {
3135        let pipeline = DispatchPipeline::with_defaults();
3136        let mut ctx = Context::new(BrainWave::Gamma);
3137        let tool = TestTool::new(
3138            "memory.delete",
3139            EffectRow {
3140                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3141                destructive: true,
3142                ..Default::default()
3143            },
3144        );
3145
3146        let result = pipeline
3147            .dispatch(
3148                &tool,
3149                &mut ctx,
3150                serde_json::json!({"confirm": true, "id": "0f0e0d0c-0000-0000-0000-000000000000"}),
3151            )
3152            .await;
3153        assert!(result.is_ok());
3154    }
3155
3156    #[tokio::test]
3157    async fn pipeline_firebreak_caution_disclosed_in_response() {
3158        let pipeline = DispatchPipeline::with_defaults();
3159        let mut ctx = Context::new(BrainWave::Gamma);
3160        let tool = TestTool::new(
3161            "galaxy.transfer",
3162            EffectRow {
3163                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3164                destructive: true,
3165                ..Default::default()
3166            },
3167        )
3168        .with_output(serde_json::json!({"status": "success"}));
3169
3170        let result = pipeline
3171            .dispatch(
3172                &tool,
3173                &mut ctx,
3174                serde_json::json!({"confirm": true, "from_galaxy": "codex", "note": "mv old new"}),
3175            )
3176            .await;
3177        let output = result.expect("caution must not block");
3178        let advisories = output
3179            .get("firebreak")
3180            .and_then(|f| f.get("advisories"))
3181            .and_then(|a| a.as_array())
3182            .expect("advisories must reach the response");
3183        assert_eq!(advisories.len(), 1);
3184    }
3185
3186    #[tokio::test]
3187    async fn pipeline_firebreak_dangerous_escalates_off_confirm_gate() {
3188        // A spawn-class seam tool that is NOT destructive-flagged: the
3189        // confirm gate (4b) never fires, but a dangerous payload in args
3190        // must still demand explicit confirm — the confirm-gate hardening.
3191        let pipeline = DispatchPipeline::with_defaults();
3192        let mut ctx = Context::new(BrainWave::Gamma);
3193        let tool = TestTool::new(
3194            "spawn_tool",
3195            EffectRow {
3196                spawns: true,
3197                ..Default::default()
3198            },
3199        );
3200
3201        let blocked = pipeline
3202            .dispatch(
3203                &tool,
3204                &mut ctx,
3205                serde_json::json!({"cmd": "sudo rm -r /tmp/build"}),
3206            )
3207            .await;
3208        match blocked {
3209            Err(CoreError::Governance(msg)) => {
3210                assert!(msg.contains("dangerous"), "got: {msg}");
3211                assert!(msg.contains("confirm"), "got: {msg}");
3212            }
3213            other => panic!("Expected Governance error, got {other:?}"),
3214        }
3215
3216        let allowed = pipeline
3217            .dispatch(
3218                &tool,
3219                &mut ctx,
3220                serde_json::json!({"cmd": "sudo rm -r /tmp/build", "confirm": true}),
3221            )
3222            .await;
3223        assert!(allowed.is_ok());
3224    }
3225
3226    #[tokio::test]
3227    async fn pipeline_firebreak_never_scans_prose() {
3228        // The seam is irreversible dispatches — a memory-create-style tool
3229        // recording an incident note quoting a forbidden command must pass.
3230        let pipeline = DispatchPipeline::with_defaults();
3231        let mut ctx = Context::new(BrainWave::Gamma);
3232        let tool = TestTool::new(
3233            "memory.create",
3234            EffectRow {
3235                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3236                ..Default::default()
3237            },
3238        );
3239
3240        let result = pipeline
3241            .dispatch(
3242                &tool,
3243                &mut ctx,
3244                serde_json::json!({"content": "incident: operator ran rm -rf / on the store"}),
3245            )
3246            .await;
3247        assert!(result.is_ok(), "prose is never vetoed");
3248    }
3249
3250    #[tokio::test]
3251    async fn pipeline_firebreak_disarmable_per_pipeline() {
3252        let pipeline = DispatchPipeline::with_defaults()
3253            .with_firebreak_option(None::<Arc<wm_governance::Firebreak>>);
3254        let mut ctx = Context::new(BrainWave::Gamma);
3255        let tool = TestTool::new(
3256            "destructive_tool",
3257            EffectRow {
3258                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3259                destructive: true,
3260                ..Default::default()
3261            },
3262        );
3263
3264        // Confirm gate still fires (it is outside the firebreak).
3265        let result = pipeline
3266            .dispatch(&tool, &mut ctx, serde_json::json!({}))
3267            .await;
3268        assert!(result.is_err());
3269
3270        // But the forbidden-command veto is gone.
3271        let result = pipeline
3272            .dispatch(
3273                &tool,
3274                &mut ctx,
3275                serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
3276            )
3277            .await;
3278        assert!(result.is_ok(), "disarmed pipeline must not veto");
3279    }
3280
3281    #[tokio::test]
3282    async fn pipeline_write_audit_records_destructive_confirm() {
3283        // The delete-confirm audit (P1.6): a destructive dispatch's journal
3284        // entry answers "was this confirmed?".
3285        let tmp = tempfile::tempdir().unwrap();
3286        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3287        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3288        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3289        let mut ctx = Context::new(BrainWave::Gamma);
3290
3291        let tool = TestTool::new(
3292            "memory.delete",
3293            EffectRow {
3294                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3295                destructive: true,
3296                ..Default::default()
3297            },
3298        )
3299        .with_store(store);
3300
3301        let result = pipeline
3302            .dispatch(
3303                &tool,
3304                &mut ctx,
3305                serde_json::json!({"confirm": true, "id": "abc-123"}),
3306            )
3307            .await;
3308        assert!(result.is_ok());
3309
3310        let entries = journal.scan_entries().unwrap();
3311        assert_eq!(entries.len(), 1);
3312        assert_eq!(
3313            entries[0].confirmed,
3314            Some(true),
3315            "destructive entry must record the confirm"
3316        );
3317    }
3318
3319    // ── Runtime galaxy argument enforcement tests ──────────────────────
3320
3321    #[tokio::test]
3322    async fn pipeline_compartment_production_blocks_runtime_galaxy_write_bypass() {
3323        // Tool declares writes to "codex" (allowed for production) but runtime
3324        // galaxy arg is "karma" — production should be blocked from writing karma.
3325        let pipeline = DispatchPipeline::with_defaults();
3326        let mut ctx = Context::new(BrainWave::Gamma);
3327        ctx.compartment = Some("production".into());
3328        let tool = TestTool::new(
3329            "memory_update",
3330            EffectRow {
3331                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3332                ..Default::default()
3333            },
3334        );
3335
3336        let args = serde_json::json!({"galaxy": "karma"});
3337        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3338        assert!(result.is_err());
3339        match result {
3340            Err(CoreError::Governance(msg)) => {
3341                assert!(msg.contains("production"));
3342                assert!(msg.contains("karma"));
3343                assert!(msg.contains("runtime"));
3344            }
3345            other => panic!("Expected Governance error, got {other:?}"),
3346        }
3347    }
3348
3349    #[tokio::test]
3350    async fn pipeline_compartment_production_blocks_runtime_galaxy_read_bypass() {
3351        // Tool declares reads from "codex" (allowed for production) but runtime
3352        // galaxy arg is "karma" — production should be blocked from reading karma.
3353        let pipeline = DispatchPipeline::with_defaults();
3354        let mut ctx = Context::new(BrainWave::Gamma);
3355        ctx.compartment = Some("production".into());
3356        let tool = TestTool::new(
3357            "memory_read",
3358            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3359        );
3360
3361        let args = serde_json::json!({"galaxy": "karma"});
3362        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3363        assert!(result.is_err());
3364        match result {
3365            Err(CoreError::Governance(msg)) => {
3366                assert!(msg.contains("production"));
3367                assert!(msg.contains("karma"));
3368                assert!(msg.contains("runtime"));
3369            }
3370            other => panic!("Expected Governance error, got {other:?}"),
3371        }
3372    }
3373
3374    #[tokio::test]
3375    async fn pipeline_compartment_production_allows_runtime_galaxy_same_as_declared() {
3376        // Tool declares reads from "codex" and runtime galaxy arg is also "codex"
3377        // — production should allow this (no duplicate check needed).
3378        let pipeline = DispatchPipeline::with_defaults();
3379        let mut ctx = Context::new(BrainWave::Gamma);
3380        ctx.compartment = Some("production".into());
3381        let tool = TestTool::new(
3382            "memory_read",
3383            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3384        );
3385
3386        let args = serde_json::json!({"galaxy": "codex"});
3387        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3388        assert!(result.is_ok());
3389    }
3390
3391    #[tokio::test]
3392    async fn pipeline_compartment_no_restriction_allows_runtime_galaxy() {
3393        // No compartment — runtime galaxy arg should be allowed regardless.
3394        let pipeline = DispatchPipeline::with_defaults();
3395        let mut ctx = Context::new(BrainWave::Gamma);
3396        let tool = TestTool::new(
3397            "memory_read",
3398            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3399        );
3400
3401        let args = serde_json::json!({"galaxy": "karma"});
3402        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3403        assert!(result.is_ok());
3404    }
3405
3406    #[tokio::test]
3407    async fn pipeline_compartment_production_allows_runtime_memory_galaxy() {
3408        // Production compartment — runtime galaxy arg "codex" should be allowed
3409        // since production can access all memory galaxies.
3410        let pipeline = DispatchPipeline::with_defaults();
3411        let mut ctx = Context::new(BrainWave::Gamma);
3412        ctx.compartment = Some("production".into());
3413        let tool = TestTool::new(
3414            "memory_read",
3415            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3416        );
3417
3418        let args = serde_json::json!({"galaxy": "research"});
3419        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3420        assert!(result.is_ok());
3421    }
3422
3423    #[tokio::test]
3424    async fn pipeline_compartment_production_blocks_runtime_system_galaxy() {
3425        // Production compartment — runtime galaxy arg "karma" should be blocked
3426        // since production can't access system galaxies.
3427        let pipeline = DispatchPipeline::with_defaults();
3428        let mut ctx = Context::new(BrainWave::Gamma);
3429        ctx.compartment = Some("production".into());
3430        let tool = TestTool::new(
3431            "memory_read",
3432            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3433        );
3434
3435        let args = serde_json::json!({"galaxy": "karma"});
3436        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3437        assert!(result.is_err());
3438        match result {
3439            Err(CoreError::Governance(msg)) => {
3440                assert!(msg.contains("production"));
3441                assert!(msg.contains("karma"));
3442                assert!(msg.contains("runtime"));
3443            }
3444            other => panic!("Expected Governance error, got {other:?}"),
3445        }
3446    }
3447
3448    #[tokio::test]
3449    async fn benchmark_pipeline_overhead() {
3450        let pipeline = DispatchPipeline::with_defaults();
3451        let tool = TestTool::new("bench_tool", EffectRow::pure());
3452        let args = Args::default();
3453
3454        // Warm up
3455        for _ in 0..100 {
3456            let mut ctx = Context::new(BrainWave::Gamma);
3457            let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3458        }
3459
3460        // Measure pipeline dispatch
3461        let n = 10_000;
3462        let start = std::time::Instant::now();
3463        for _ in 0..n {
3464            let mut ctx = Context::new(BrainWave::Gamma);
3465            let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3466        }
3467        let pipeline_ns = start.elapsed().as_nanos() / n;
3468
3469        // Measure direct tool call (no pipeline)
3470        let start = std::time::Instant::now();
3471        for _ in 0..n {
3472            let mut ctx = Context::new(BrainWave::Gamma);
3473            let _ = tool.call(&mut ctx, args.clone()).await;
3474        }
3475        let direct_ns = start.elapsed().as_nanos() / n;
3476
3477        let overhead_ns = pipeline_ns.saturating_sub(direct_ns);
3478        println!(
3479            "\n  Pipeline: {pipeline_ns} ns/call | Direct: {direct_ns} ns/call | Overhead: {overhead_ns} ns/call"
3480        );
3481
3482        // Pipeline overhead should be under 5µs per call (5000 ns) in release builds.
3483        // Debug builds have unoptimized async/await overhead, so we only assert
3484        // when compiled with optimizations.
3485        #[cfg(not(debug_assertions))]
3486        assert!(
3487            overhead_ns < 5_000,
3488            "Pipeline overhead {overhead_ns} ns/call exceeds 5µs budget"
3489        );
3490    }
3491
3492    /// Counting receipt hook for the authority-seam tests.
3493    struct CountingReceiptHook {
3494        calls: std::sync::atomic::AtomicU64,
3495        passes: std::sync::atomic::AtomicU64,
3496        pass_arg_seen: std::sync::atomic::AtomicBool,
3497    }
3498
3499    impl ReceiptDispatchHook for CountingReceiptHook {
3500        fn on_authority_dispatch(&self, dispatch: AuthorityDispatch<'_>) {
3501            self.calls
3502                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3503            if dispatch.pass.is_some() {
3504                self.passes
3505                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3506            }
3507            if dispatch.args.get("mandala_pass").is_some() {
3508                self.pass_arg_seen
3509                    .store(true, std::sync::atomic::Ordering::Relaxed);
3510            }
3511        }
3512    }
3513
3514    fn counting_hook() -> Arc<CountingReceiptHook> {
3515        Arc::new(CountingReceiptHook {
3516            calls: std::sync::atomic::AtomicU64::new(0),
3517            passes: std::sync::atomic::AtomicU64::new(0),
3518            pass_arg_seen: std::sync::atomic::AtomicBool::new(false),
3519        })
3520    }
3521
3522    fn destructive_tool(name: &str) -> TestTool {
3523        TestTool::new(
3524            name,
3525            EffectRow {
3526                destructive: true,
3527                ..EffectRow::default()
3528            },
3529        )
3530    }
3531
3532    #[tokio::test]
3533    async fn receipt_hook_fires_only_for_successful_destructive_dispatch() {
3534        let hook = counting_hook();
3535        let pipeline =
3536            DispatchPipeline::with_defaults().with_receipt_hook_option(Some(hook.clone()));
3537        assert!(pipeline.receipt_hook().is_some());
3538
3539        // Non-destructive dispatch: the hook must not fire.
3540        let read_tool = TestTool::new(
3541            "test.read",
3542            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3543        );
3544        let result = pipeline
3545            .dispatch(
3546                &read_tool,
3547                &mut Context::new(BrainWave::Beta),
3548                Args::default(),
3549            )
3550            .await;
3551        assert!(result.is_ok());
3552        assert_eq!(
3553            hook.calls.load(std::sync::atomic::Ordering::Relaxed),
3554            0,
3555            "read dispatch must not hit the authority seam"
3556        );
3557
3558        // Successful destructive dispatch with confirm: fires once.
3559        let tool = destructive_tool("test.destructive");
3560        let result = pipeline
3561            .dispatch(
3562                &tool,
3563                &mut Context::new(BrainWave::Beta),
3564                serde_json::json!({"confirm": true}),
3565            )
3566            .await;
3567        assert!(result.is_ok(), "{result:?}");
3568        assert_eq!(
3569            hook.calls.load(std::sync::atomic::Ordering::Relaxed),
3570            1,
3571            "one authority-seam emission per successful destructive dispatch"
3572        );
3573    }
3574
3575    #[tokio::test]
3576    async fn receipt_hook_disabled_adds_no_measurable_dispatch_cost() {
3577        let tool = destructive_tool("test.destructive.timing");
3578        let args = serde_json::json!({"confirm": true});
3579        let n: u128 = 2_000;
3580
3581        // Disabled (default): the seam is a single Option check.
3582        let plain = DispatchPipeline::with_defaults();
3583        assert!(plain.receipt_hook().is_none());
3584        let start = std::time::Instant::now();
3585        for _ in 0..n {
3586            let _ = plain
3587                .dispatch(&tool, &mut Context::new(BrainWave::Beta), args.clone())
3588                .await;
3589        }
3590        let disabled_ns = start.elapsed().as_nanos() / n;
3591
3592        // Enabled with a no-op hook: one virtual call per dispatch.
3593        let hook = counting_hook();
3594        let hooked = DispatchPipeline::with_defaults().with_receipt_hook_option(Some(hook.clone()));
3595        let start = std::time::Instant::now();
3596        let mut successes: u128 = 0;
3597        for _ in 0..n {
3598            if hooked
3599                .dispatch(&tool, &mut Context::new(BrainWave::Beta), args.clone())
3600                .await
3601                .is_ok()
3602            {
3603                successes += 1;
3604            }
3605        }
3606        let enabled_ns = start.elapsed().as_nanos() / n;
3607
3608        println!(
3609            "\n  receipts hook: disabled {disabled_ns} ns/call | noop-hook {enabled_ns} ns/call"
3610        );
3611        // A no-op hook is one virtual call; even debug builds must stay far
3612        // under a 5µs/call delta. The disabled path itself has no work to
3613        // budget — the Option check is folded into the success branch.
3614        assert!(
3615            enabled_ns.saturating_sub(disabled_ns) < 5_000,
3616            "no-op receipt hook added {} ns/call (budget 5000)",
3617            enabled_ns.saturating_sub(disabled_ns)
3618        );
3619        assert!(
3620            successes > 0,
3621            "timing loop observed no successful destructive dispatches"
3622        );
3623        assert_eq!(
3624            u128::from(hook.calls.load(std::sync::atomic::Ordering::Relaxed)),
3625            successes,
3626            "hook must observe exactly the successful authority-seam dispatches"
3627        );
3628    }
3629
3630    /// Fake offline pass verifier for the S2 seam tests.
3631    struct FakePassGate;
3632
3633    impl PassGate for FakePassGate {
3634        fn verify(&self, token: &str, _tool: &str) -> std::result::Result<PassEvidence, String> {
3635            if token == "good-token" {
3636                Ok(PassEvidence {
3637                    issuer: "gate:gate-lite-1".into(),
3638                    subject: "did:key:zSubject".into(),
3639                    audience: "gate-lite".into(),
3640                    gate_class: "gate-lite".into(),
3641                    slot_class: "small".into(),
3642                    gate_did: "did:key:zGate".into(),
3643                    policy_version: "2026-09-17.1".into(),
3644                    expires_at: 4_102_444_800,
3645                    quotas: PassQuotas {
3646                        cpu_ms: 1,
3647                        mem_mb: 2,
3648                        disk_mb: 3,
3649                        wall_ms: 4,
3650                    },
3651                    budget: None,
3652                    jti: "jti".into(),
3653                    token_digest: "sha256:00".into(),
3654                })
3655            } else {
3656                Err("bad pass".into())
3657            }
3658        }
3659    }
3660
3661    #[tokio::test]
3662    async fn pass_gate_required_refuses_without_a_pass_and_optional_is_unchanged() {
3663        let tool = destructive_tool("test.pass.required");
3664        let args = serde_json::json!({"confirm": true});
3665
3666        let required = DispatchPipeline::with_defaults()
3667            .with_pass_gate_option(Some(Arc::new(FakePassGate)), PassMode::Required);
3668        let result = required
3669            .dispatch(&tool, &mut Context::new(BrainWave::Beta), args.clone())
3670            .await;
3671        assert!(
3672            matches!(result, Err(CoreError::Governance(_))),
3673            "{result:?}"
3674        );
3675
3676        let optional = DispatchPipeline::with_defaults()
3677            .with_pass_gate_option(Some(Arc::new(FakePassGate)), PassMode::Optional);
3678        let result = optional
3679            .dispatch(&tool, &mut Context::new(BrainWave::Beta), args)
3680            .await;
3681        assert!(result.is_ok(), "{result:?}");
3682    }
3683
3684    #[tokio::test]
3685    async fn pass_gate_verifies_and_strips_the_token_before_dispatch() {
3686        let hook = counting_hook();
3687        let tool = destructive_tool("test.pass.governed");
3688        let pipeline = DispatchPipeline::with_defaults()
3689            .with_receipt_hook_option(Some(hook.clone()))
3690            .with_pass_gate_option(Some(Arc::new(FakePassGate)), PassMode::Optional);
3691
3692        let result = pipeline
3693            .dispatch(
3694                &tool,
3695                &mut Context::new(BrainWave::Beta),
3696                serde_json::json!({"confirm": true, "mandala_pass": "good-token"}),
3697            )
3698            .await;
3699        assert!(result.is_ok(), "{result:?}");
3700        assert_eq!(hook.calls.load(std::sync::atomic::Ordering::Relaxed), 1);
3701        assert_eq!(
3702            hook.passes.load(std::sync::atomic::Ordering::Relaxed),
3703            1,
3704            "pass evidence must reach the authority-seam hook"
3705        );
3706        assert!(
3707            !hook
3708                .pass_arg_seen
3709                .load(std::sync::atomic::Ordering::Relaxed),
3710            "the pass token must be stripped before the tool/args digest"
3711        );
3712
3713        // Invalid pass is refused before execution and emits nothing.
3714        let result = pipeline
3715            .dispatch(
3716                &tool,
3717                &mut Context::new(BrainWave::Beta),
3718                serde_json::json!({"confirm": true, "mandala_pass": "bad-token"}),
3719            )
3720            .await;
3721        assert!(
3722            matches!(result, Err(CoreError::Governance(_))),
3723            "{result:?}"
3724        );
3725        assert_eq!(
3726            hook.calls.load(std::sync::atomic::Ordering::Relaxed),
3727            1,
3728            "refused dispatch must not emit evidence"
3729        );
3730    }
3731}