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