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_strict_refusal_is_typed_and_distinct_from_starvation() {
1315        // Governance refusal (strict mode via system stress, Beta brain wave
1316        // so the availability gate admits the call): coordination lease
1317        // acquisition is refused with the typed AHIMSA violation.
1318        let pipeline = DispatchPipeline::with_defaults();
1319        pipeline
1320            .dharma_gate()
1321            .update_homeostasis(wm_governance::Homeostasis {
1322                cpu_load: 0.95,
1323                memory_pressure: 0.95,
1324                active: true,
1325            });
1326        let mut ctx = Context::new(BrainWave::Beta);
1327        let tool = TestTool::new(
1328            "stress_probe",
1329            EffectRow {
1330                reads: vec![wm_core::Resource::Filesystem],
1331                writes: vec![wm_core::Resource::CoordinationLease],
1332                ..Default::default()
1333            },
1334        );
1335        let governance = pipeline
1336            .dispatch(&tool, &mut ctx, Args::default())
1337            .await
1338            .expect_err("strict mode must refuse coordination lease acquisition");
1339        let text = governance.to_string();
1340        assert!(text.contains("VIOLATION_AHIMSA"), "{text}");
1341
1342        // First-run starvation: low self-model confidence refuses writes with
1343        // the typed homeostasis-limit error naming the frozen pin — a
1344        // different class from the governance refusal, and reads stay open.
1345        let pipeline = DispatchPipeline::with_defaults();
1346        let mut ctx = Context::new(BrainWave::Gamma);
1347        ctx.self_model_confidence = 0.3;
1348        let write_tool = TestTool::new(
1349            "stress_probe",
1350            EffectRow {
1351                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1352                ..Default::default()
1353            },
1354        );
1355        let starvation = pipeline
1356            .dispatch(&write_tool, &mut ctx, Args::default())
1357            .await
1358            .expect_err("low confidence must refuse writes");
1359        let text = starvation.to_string();
1360        assert!(text.contains("self-model confidence"), "{text}");
1361        assert!(text.contains("WM_HOMEOSTASIS_FROZEN"), "{text}");
1362        assert!(
1363            !text.contains("VIOLATION_AHIMSA"),
1364            "refusal classes must be distinguishable: {text}"
1365        );
1366
1367        let read_tool = TestTool::new(
1368            "stress_probe_read",
1369            EffectRow {
1370                reads: vec![wm_core::Resource::Galaxy("codex".into())],
1371                ..Default::default()
1372            },
1373        );
1374        assert!(
1375            pipeline
1376                .dispatch(&read_tool, &mut ctx, Args::default())
1377                .await
1378                .is_ok(),
1379            "starvation must not block reads"
1380        );
1381    }
1382
1383    #[tokio::test]
1384    async fn pipeline_dharma_confirm_passes_brain_wave_strict_for_destructive() {
1385        // 9.1.6: explicit `confirm: true` (deliberate operator intent)
1386        // passes the Theta/Delta brain-wave strict arm; stressed
1387        // homeostasis must still block (covered by dharma_gate unit tests).
1388        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(Arc::new(
1389            ResourceRules::new(ResourceRulesConfig {
1390                require_human_review: false,
1391                ..Default::default()
1392            }),
1393        ));
1394        let mut ctx = Context::new(BrainWave::Theta);
1395        let tool = TestTool::new(
1396            "destructive_tool",
1397            EffectRow {
1398                writes: vec![wm_core::Resource::Filesystem],
1399                ..Default::default()
1400            },
1401        );
1402        let result = pipeline
1403            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1404            .await;
1405        assert!(
1406            result.is_ok(),
1407            "confirmed destructive dispatch must pass brain-wave strict: {result:?}"
1408        );
1409    }
1410
1411    #[tokio::test]
1412    async fn pipeline_capability_gate_strict_blocks_uncredentialed() {
1413        let pipeline =
1414            DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1415        let mut ctx = Context::new(BrainWave::Gamma);
1416        let tool = TestTool::new(
1417            "capability_tool",
1418            EffectRow {
1419                invokes: vec![wm_core::Capability::MemoryWrite],
1420                ..Default::default()
1421            },
1422        );
1423
1424        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1425        match result {
1426            Err(CoreError::Governance(msg)) => {
1427                assert!(msg.contains("capability gate"), "{msg}");
1428                assert!(msg.contains("memory:write"), "{msg}");
1429            }
1430            other => panic!("Expected capability refusal, got {other:?}"),
1431        }
1432    }
1433
1434    #[tokio::test]
1435    async fn pipeline_capability_gate_strict_allows_valid_token() {
1436        let pipeline =
1437            DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1438        let mut ctx = Context::new(BrainWave::Gamma);
1439        let tool = TestTool::new(
1440            "capability_tool_ok",
1441            EffectRow {
1442                invokes: vec![wm_core::Capability::MemoryWrite],
1443                ..Default::default()
1444            },
1445        );
1446
1447        let mut issuer = wm_governance::engagement_tokens::EngagementIssuer::with_keypair(
1448            wm_governance::network_profile::AgentKeypair::from_seed([7u8; 32]),
1449        );
1450        let issuer_key = issuer.signer_public_key_hex();
1451        let token = issuer.issue(
1452            "tester",
1453            wm_governance::engagement_tokens::EngagementScope::Poc,
1454            "rules-hash",
1455            Some(3600),
1456        );
1457        let args = serde_json::json!({
1458            "_engagement": { "token": token, "issuer_public_key": issuer_key }
1459        });
1460
1461        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
1462        assert!(result.is_ok(), "valid Poc token should pass: {result:?}");
1463    }
1464
1465    #[tokio::test]
1466    async fn pipeline_capability_gate_advisory_allows_uncredentialed() {
1467        let pipeline =
1468            DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Advisory);
1469        let mut ctx = Context::new(BrainWave::Gamma);
1470        let tool = TestTool::new(
1471            "capability_tool_advisory",
1472            EffectRow {
1473                invokes: vec![wm_core::Capability::MemoryWrite],
1474                ..Default::default()
1475            },
1476        );
1477
1478        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1479        assert!(result.is_ok(), "advisory mode must not block: {result:?}");
1480    }
1481
1482    #[tokio::test]
1483    async fn pipeline_rate_limit_blocks_excess() {
1484        let rate_limiter = Arc::new(RateLimiter::new(1000, 2, 0));
1485        let pipeline = DispatchPipeline::new(
1486            rate_limiter,
1487            Arc::new(CircuitBreakerRegistry::default()),
1488            Arc::new(DharmaGate::default()),
1489            None,
1490        );
1491
1492        let mut ctx = Context::new(BrainWave::Gamma);
1493        let tool = TestTool::new("limited_tool", EffectRow::pure());
1494
1495        assert!(
1496            pipeline
1497                .dispatch(&tool, &mut ctx, Args::default())
1498                .await
1499                .is_ok()
1500        );
1501        assert!(
1502            pipeline
1503                .dispatch(&tool, &mut ctx, Args::default())
1504                .await
1505                .is_ok()
1506        );
1507        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1508        assert!(result.is_err());
1509        match result {
1510            Err(CoreError::RateLimited(_)) => {}
1511            other => panic!("Expected RateLimited error, got {other:?}"),
1512        }
1513    }
1514
1515    #[tokio::test]
1516    async fn pipeline_circuit_breaker_opens_on_repeated_failures() {
1517        let breakers = Arc::new(CircuitBreakerRegistry::new(
1518            crate::circuit_breaker::BreakerConfig {
1519                failure_threshold: 3,
1520                window: std::time::Duration::from_secs(10),
1521                cooldown: std::time::Duration::from_secs(30),
1522            },
1523        ));
1524        let pipeline = DispatchPipeline::new(
1525            Arc::new(RateLimiter::new(10000, 100, 100)),
1526            breakers.clone(),
1527            Arc::new(DharmaGate::default()),
1528            None,
1529        );
1530
1531        let mut ctx = Context::new(BrainWave::Gamma);
1532        let tool = TestTool::failing("flaky_tool");
1533
1534        for _ in 0..3 {
1535            let _ = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1536        }
1537
1538        assert_eq!(
1539            breakers.state("flaky_tool"),
1540            crate::circuit_breaker::BreakerState::Open
1541        );
1542
1543        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1544        assert!(result.is_err());
1545        match result {
1546            Err(CoreError::CircuitBreaker(_)) => {}
1547            other => panic!("Expected CircuitBreaker error, got {other:?}"),
1548        }
1549    }
1550
1551    /// 2026-09-15 audit: a caller's malformed requests must not fast-fail the
1552    /// next valid request — only backend/execution failures count.
1553    #[tokio::test]
1554    async fn client_validation_errors_do_not_trip_the_breaker() {
1555        let breakers = Arc::new(CircuitBreakerRegistry::new(
1556            crate::circuit_breaker::BreakerConfig {
1557                failure_threshold: 3,
1558                window: std::time::Duration::from_secs(10),
1559                cooldown: std::time::Duration::from_secs(30),
1560            },
1561        ));
1562        let pipeline = DispatchPipeline::new(
1563            Arc::new(RateLimiter::new(10000, 100, 100)),
1564            breakers.clone(),
1565            Arc::new(DharmaGate::default()),
1566            None,
1567        );
1568        let mut ctx = Context::new(BrainWave::Gamma);
1569
1570        // Five invalid-galaxy-style caller errors: the shape that used to
1571        // trip the breaker and block the next correct call.
1572        let bad = TestTool::returning_error("validated_tool", || {
1573            CoreError::InvalidArgs("unknown galaxy".into())
1574        });
1575        for _ in 0..5 {
1576            let err = pipeline
1577                .dispatch(&bad, &mut ctx, Args::default())
1578                .await
1579                .unwrap_err();
1580            assert!(matches!(err, CoreError::InvalidArgs(_)));
1581        }
1582        assert_eq!(
1583            breakers.state("validated_tool"),
1584            crate::circuit_breaker::BreakerState::Closed,
1585            "caller errors must not open the breaker"
1586        );
1587
1588        // Governance refusals likewise stay caller/request-scoped.
1589        let governed = TestTool::returning_error("validated_tool", || {
1590            CoreError::Governance("budget exceeded for writes".into())
1591        });
1592        for _ in 0..5 {
1593            let _ = pipeline
1594                .dispatch(&governed, &mut ctx, Args::default())
1595                .await;
1596        }
1597        assert_eq!(
1598            breakers.state("validated_tool"),
1599            crate::circuit_breaker::BreakerState::Closed,
1600            "governance refusals must not open the breaker"
1601        );
1602
1603        // A healthy call still succeeds immediately.
1604        let good = TestTool::new("validated_tool", EffectRow::pure());
1605        pipeline
1606            .dispatch(&good, &mut ctx, Args::default())
1607            .await
1608            .expect("valid call after caller errors");
1609    }
1610
1611    #[tokio::test]
1612    async fn rate_limit_error_names_its_governor() {
1613        let pipeline = DispatchPipeline::new(
1614            Arc::new(RateLimiter::new(1000, 1, 0)),
1615            Arc::new(CircuitBreakerRegistry::default()),
1616            Arc::new(DharmaGate::default()),
1617            None,
1618        );
1619        let mut ctx = Context::new(BrainWave::Gamma);
1620        let tool = TestTool::new("bursty_tool", EffectRow::pure());
1621        pipeline
1622            .dispatch(&tool, &mut ctx, Args::default())
1623            .await
1624            .unwrap();
1625        let err = pipeline
1626            .dispatch(&tool, &mut ctx, Args::default())
1627            .await
1628            .unwrap_err();
1629        let text = err.to_string();
1630        assert!(
1631            text.contains("request rate limit") && text.contains("retry after"),
1632            "rate limit must name its category and retry hint: {text}"
1633        );
1634    }
1635
1636    #[tokio::test]
1637    async fn pipeline_karma_ledger_records() {
1638        let tmp = tempfile::tempdir().unwrap();
1639        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1640        let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1641
1642        let pipeline = DispatchPipeline::new(
1643            Arc::new(RateLimiter::default()),
1644            Arc::new(CircuitBreakerRegistry::default()),
1645            Arc::new(DharmaGate::default()),
1646            Some(ledger.clone()),
1647        );
1648
1649        let mut ctx = Context::new(BrainWave::Gamma);
1650        let tool = TestTool::new("karma_test_tool", EffectRow::pure());
1651
1652        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1653        assert!(result.is_ok());
1654        assert_eq!(ledger.next_id(), 1);
1655        assert_eq!(ctx.karma_debt, 0.0);
1656    }
1657
1658    #[tokio::test]
1659    async fn pipeline_karma_debt_updates_context() {
1660        let tmp = tempfile::tempdir().unwrap();
1661        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1662        let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1663
1664        let pipeline = DispatchPipeline::new(
1665            Arc::new(RateLimiter::default()),
1666            Arc::new(CircuitBreakerRegistry::default()),
1667            Arc::new(DharmaGate::default()),
1668            Some(ledger),
1669        );
1670
1671        let mut ctx = Context::new(BrainWave::Gamma);
1672        let tool = TestTool::new(
1673            "wasteful_tool",
1674            EffectRow {
1675                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1676                ..Default::default()
1677            },
1678        );
1679
1680        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1681        assert!(result.is_ok());
1682        assert!(
1683            (ctx.karma_debt - 0.2).abs() < 0.001,
1684            "Context karma_debt should be 0.2, got {}",
1685            ctx.karma_debt
1686        );
1687    }
1688
1689    #[tokio::test]
1690    async fn pipeline_karma_batched_e2e() {
1691        // E2E: Full dispatch cycle with batched karma writes produces
1692        // correct total_debt() and chain integrity after flush.
1693        let tmp = tempfile::tempdir().unwrap();
1694        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1695        let ledger = Arc::new(KarmaLedger::with_flush_threshold(store.clone(), 100).unwrap());
1696
1697        let pipeline = DispatchPipeline::new(
1698            Arc::new(RateLimiter::default()),
1699            Arc::new(CircuitBreakerRegistry::default()),
1700            Arc::new(DharmaGate::default()),
1701            Some(ledger.clone()),
1702        );
1703
1704        let mut ctx = Context::new(BrainWave::Gamma);
1705
1706        // Dispatch 10 honest tools (no debt) and 10 wasteful tools (0.2 debt each)
1707        let honest_tool = TestTool::new("honest_tool", EffectRow::pure());
1708        let wasteful_tool = TestTool::new(
1709            "wasteful_tool",
1710            EffectRow {
1711                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1712                ..Default::default()
1713            },
1714        );
1715
1716        for _ in 0..10 {
1717            let result = pipeline
1718                .dispatch(&honest_tool, &mut ctx, Args::default())
1719                .await;
1720            assert!(result.is_ok());
1721        }
1722        for _ in 0..10 {
1723            let result = pipeline
1724                .dispatch(&wasteful_tool, &mut ctx, Args::default())
1725                .await;
1726            assert!(result.is_ok());
1727        }
1728
1729        // 20 entries should be buffered (not yet in LMDB)
1730        assert_eq!(ledger.next_id(), 20);
1731        assert_eq!(
1732            ledger.pending_count(),
1733            20,
1734            "All 20 entries should be pending before flush"
1735        );
1736
1737        // total_debt() reads from in-memory chain state — should reflect all 20
1738        let debt = ledger.total_debt();
1739        assert!(
1740            (debt - 2.0).abs() < 0.001,
1741            "Total debt should be 2.0 (10 x 0.2), got {debt}"
1742        );
1743
1744        // Flush to persist all entries in one batch transaction
1745        ledger.flush().unwrap();
1746        assert_eq!(ledger.pending_count(), 0);
1747
1748        // Verify chain integrity after batched flush
1749        let result = ledger.verify_integrity().unwrap();
1750        assert!(
1751            result.valid,
1752            "Chain should be valid after batched flush: {:?}",
1753            result.violation
1754        );
1755        assert_eq!(result.entries_verified, 20);
1756
1757        // Verify entries are persisted by creating a new ledger from same store
1758        let ledger2 = KarmaLedger::new(store).unwrap();
1759        assert_eq!(
1760            ledger2.next_id(),
1761            20,
1762            "Next ID should persist across instances"
1763        );
1764        let entries = ledger2.scan_entries().unwrap();
1765        assert_eq!(
1766            entries.len(),
1767            20,
1768            "All 20 entries should be persisted in LMDB"
1769        );
1770
1771        // Verify total debt persisted
1772        let debt2 = ledger2.total_debt();
1773        assert!(
1774            (debt2 - 2.0).abs() < 0.001,
1775            "Total debt should persist as 2.0, got {debt2}"
1776        );
1777
1778        // Verify chain integrity on the reloaded ledger
1779        let result2 = ledger2.verify_integrity().unwrap();
1780        assert!(result2.valid, "Chain should be valid on reloaded ledger");
1781        assert_eq!(result2.entries_verified, 20);
1782    }
1783
1784    #[tokio::test]
1785    async fn pipeline_coherence_gate_blocks_writes() {
1786        let pipeline = DispatchPipeline::with_defaults();
1787        let mut ctx = Context::new(BrainWave::Gamma);
1788        ctx.citta_coherence = 0.1; // Below 0.3 threshold
1789        let tool = TestTool::new(
1790            "write_tool",
1791            EffectRow {
1792                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1793                ..Default::default()
1794            },
1795        );
1796
1797        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1798        assert!(result.is_err());
1799        match result {
1800            Err(CoreError::Governance(msg)) => {
1801                assert!(msg.contains("coherence"));
1802            }
1803            other => panic!("Expected Governance error, got {other:?}"),
1804        }
1805    }
1806
1807    #[tokio::test]
1808    async fn pipeline_coherence_gate_allows_reads() {
1809        let pipeline = DispatchPipeline::with_defaults();
1810        let mut ctx = Context::new(BrainWave::Gamma);
1811        ctx.citta_coherence = 0.1; // Below threshold, but no writes
1812        let tool = TestTool::new("read_tool", EffectRow::pure());
1813
1814        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1815        assert!(result.is_ok());
1816    }
1817
1818    #[tokio::test]
1819    async fn pipeline_coherence_gate_allows_writes_when_coherent() {
1820        let pipeline = DispatchPipeline::with_defaults();
1821        let mut ctx = Context::new(BrainWave::Gamma);
1822        ctx.citta_coherence = 0.5; // Above threshold
1823        let tool = TestTool::new(
1824            "write_tool",
1825            EffectRow {
1826                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1827                ..Default::default()
1828            },
1829        );
1830
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_confidence_blocks_writes() {
1837        let pipeline = DispatchPipeline::with_defaults();
1838        let mut ctx = Context::new(BrainWave::Gamma);
1839        ctx.self_model_confidence = 0.3; // Below 0.5 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        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1849        assert!(result.is_err());
1850        match result {
1851            Err(CoreError::Governance(msg)) => {
1852                assert!(msg.contains("confidence"));
1853                assert!(msg.contains("conservative"));
1854            }
1855            other => panic!("Expected Governance error, got {other:?}"),
1856        }
1857    }
1858
1859    #[tokio::test]
1860    async fn pipeline_low_confidence_allows_reads() {
1861        let pipeline = DispatchPipeline::with_defaults();
1862        let mut ctx = Context::new(BrainWave::Gamma);
1863        ctx.self_model_confidence = 0.3; // Below threshold, but no writes
1864        let tool = TestTool::new("read_tool", EffectRow::pure());
1865
1866        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1867        assert!(result.is_ok());
1868    }
1869
1870    #[tokio::test]
1871    async fn pipeline_high_confidence_allows_writes() {
1872        let pipeline = DispatchPipeline::with_defaults();
1873        let mut ctx = Context::new(BrainWave::Gamma);
1874        ctx.self_model_confidence = 0.8; // Above threshold
1875        let tool = TestTool::new(
1876            "write_tool",
1877            EffectRow {
1878                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1879                ..Default::default()
1880            },
1881        );
1882
1883        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1884        assert!(result.is_ok());
1885    }
1886
1887    #[tokio::test]
1888    async fn pipeline_high_caution_warns_on_writes() {
1889        let pipeline = DispatchPipeline::with_defaults();
1890        let mut ctx = Context::new(BrainWave::Gamma);
1891        ctx.drive_caution = 0.9; // Above 0.85 threshold
1892        let tool = TestTool::new(
1893            "write_tool",
1894            EffectRow {
1895                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1896                ..Default::default()
1897            },
1898        );
1899
1900        // Should still succeed — caution is a warning, not a block
1901        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1902        assert!(result.is_ok());
1903    }
1904
1905    #[tokio::test]
1906    async fn pipeline_low_energy_warns_on_writes() {
1907        let pipeline = DispatchPipeline::with_defaults();
1908        let mut ctx = Context::new(BrainWave::Gamma);
1909        ctx.drive_energy = 0.1; // Below 0.15 threshold
1910        let tool = TestTool::new(
1911            "write_tool",
1912            EffectRow {
1913                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1914                ..Default::default()
1915            },
1916        );
1917
1918        // Should still succeed — low energy is a warning, not a block
1919        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1920        assert!(result.is_ok());
1921    }
1922
1923    #[tokio::test]
1924    async fn pipeline_drive_gates_dont_affect_reads() {
1925        let pipeline = DispatchPipeline::with_defaults();
1926        let mut ctx = Context::new(BrainWave::Gamma);
1927        ctx.drive_caution = 0.95;
1928        ctx.drive_energy = 0.05;
1929        let tool = TestTool::new("read_tool", EffectRow::pure());
1930
1931        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1932        assert!(result.is_ok());
1933    }
1934
1935    #[tokio::test]
1936    async fn pipeline_destructive_blocked_without_confirm() {
1937        let pipeline = DispatchPipeline::with_defaults();
1938        let mut ctx = Context::new(BrainWave::Gamma);
1939        let tool = TestTool::new(
1940            "destructive_tool",
1941            EffectRow {
1942                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1943                destructive: true,
1944                ..Default::default()
1945            },
1946        );
1947
1948        let result = pipeline
1949            .dispatch(&tool, &mut ctx, serde_json::json!({}))
1950            .await;
1951        assert!(result.is_err());
1952        match result {
1953            Err(CoreError::Governance(msg)) => {
1954                assert!(msg.contains("destructive"));
1955                assert!(msg.contains("confirm"));
1956            }
1957            other => panic!("Expected Governance error, got {other:?}"),
1958        }
1959    }
1960
1961    #[tokio::test]
1962    async fn pipeline_destructive_allowed_with_confirm() {
1963        let pipeline = DispatchPipeline::with_defaults();
1964        let mut ctx = Context::new(BrainWave::Gamma);
1965        let tool = TestTool::new(
1966            "destructive_tool",
1967            EffectRow {
1968                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1969                destructive: true,
1970                ..Default::default()
1971            },
1972        );
1973
1974        let result = pipeline
1975            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1976            .await;
1977        assert!(result.is_ok());
1978    }
1979
1980    #[tokio::test]
1981    async fn pipeline_destructive_blocked_with_false_confirm() {
1982        let pipeline = DispatchPipeline::with_defaults();
1983        let mut ctx = Context::new(BrainWave::Gamma);
1984        let tool = TestTool::new(
1985            "destructive_tool",
1986            EffectRow {
1987                writes: vec![wm_core::Resource::Galaxy("codex".into())],
1988                destructive: true,
1989                ..Default::default()
1990            },
1991        );
1992
1993        let result = pipeline
1994            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": false}))
1995            .await;
1996        assert!(result.is_err());
1997    }
1998
1999    #[tokio::test]
2000    async fn pipeline_compartment_no_restriction_allows_all() {
2001        let pipeline = DispatchPipeline::with_defaults();
2002        let mut ctx = Context::new(BrainWave::Gamma);
2003        // No compartment set — full access
2004        let tool = TestTool::new(
2005            "write_tool",
2006            EffectRow {
2007                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2008                ..Default::default()
2009            },
2010        );
2011
2012        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2013        assert!(result.is_ok());
2014    }
2015
2016    #[tokio::test]
2017    async fn pipeline_compartment_sandbox_blocks_write_to_codex() {
2018        let pipeline = DispatchPipeline::with_defaults();
2019        let mut ctx = Context::new(BrainWave::Gamma);
2020        ctx.compartment = Some("sandbox".into());
2021        let tool = TestTool::new(
2022            "write_tool",
2023            EffectRow {
2024                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2025                ..Default::default()
2026            },
2027        );
2028
2029        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2030        assert!(result.is_err());
2031        match result {
2032            Err(CoreError::Governance(msg)) => {
2033                assert!(msg.contains("sandbox"));
2034                assert!(msg.contains("codex"));
2035            }
2036            other => panic!("Expected Governance error, got {other:?}"),
2037        }
2038    }
2039
2040    #[tokio::test]
2041    async fn pipeline_asserted_user_id_confers_no_authority() {
2042        // P-DEPUTY-2 (2026-09-10, Glama confused-deputy series): the
2043        // client-asserted `_meta.user_id` label is attribution only — it
2044        // must never widen compartment authority. A sandbox dispatch
2045        // labeled as any privileged user is still a sandbox dispatch.
2046        let pipeline = DispatchPipeline::with_defaults();
2047        let mut ctx = Context::new(BrainWave::Gamma);
2048        ctx.compartment = Some("sandbox".into());
2049        ctx.user_id = Some("ceo".into());
2050        let tool = TestTool::new(
2051            "write_tool",
2052            EffectRow {
2053                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2054                ..Default::default()
2055            },
2056        );
2057
2058        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2059        assert!(result.is_err());
2060        match result {
2061            Err(CoreError::Governance(msg)) => {
2062                assert!(msg.contains("sandbox"));
2063                assert!(msg.contains("codex"));
2064            }
2065            other => panic!("Expected Governance error, got {other:?}"),
2066        }
2067    }
2068
2069    #[tokio::test]
2070    async fn pipeline_routes_store_scoped_tools_through_executor() {
2071        // P-SANDBOX-3 (Landlock v1): `StoreScoped` marks route through the
2072        // executor when one is attached; plain tools keep the ambient path.
2073        use crate::sandbox_exec::ScopedSandboxExecutor;
2074        use std::sync::atomic::{AtomicU64, Ordering};
2075        let calls = Arc::new(AtomicU64::new(0));
2076        let counter = Arc::clone(&calls);
2077        let executor = Arc::new(ScopedSandboxExecutor::new(move || {
2078            counter.fetch_add(1, Ordering::SeqCst);
2079            Ok(())
2080        }));
2081        let pipeline =
2082            DispatchPipeline::with_defaults().with_sandbox_executor(Some(Arc::clone(&executor)));
2083        let mut ctx = Context::new(BrainWave::Gamma);
2084
2085        let scoped = TestTool::new(
2086            "scoped_tool",
2087            EffectRow {
2088                sandbox: Sandbox::StoreScoped,
2089                ..Default::default()
2090            },
2091        );
2092        assert!(
2093            pipeline
2094                .dispatch(&scoped, &mut ctx, Args::default())
2095                .await
2096                .is_ok()
2097        );
2098        assert_eq!(calls.load(Ordering::SeqCst), 1, "scoped tool must confine");
2099
2100        let plain = TestTool::new("plain_tool", EffectRow::pure());
2101        assert!(
2102            pipeline
2103                .dispatch(&plain, &mut ctx, Args::default())
2104                .await
2105                .is_ok()
2106        );
2107        assert_eq!(
2108            calls.load(Ordering::SeqCst),
2109            1,
2110            "plain tools must not ride the sandbox path"
2111        );
2112        assert_eq!(executor.stats(), (1, 0, 0));
2113
2114        // A scoped tool with no executor attached is inert (v0 behavior).
2115        let bare = DispatchPipeline::with_defaults();
2116        let scoped2 = TestTool::new(
2117            "scoped_tool",
2118            EffectRow {
2119                sandbox: Sandbox::StoreScoped,
2120                ..Default::default()
2121            },
2122        );
2123        assert!(
2124            bare.dispatch(&scoped2, &mut ctx, Args::default())
2125                .await
2126                .is_ok()
2127        );
2128    }
2129
2130    #[tokio::test]
2131    async fn pipeline_injects_subprocess_policy_and_discloses() {
2132        // B2: declared `Sandbox::Subprocess` tools get a runner-backed
2133        // policy on their context before the call, and the active runner is
2134        // disclosed on the response.
2135        use crate::subprocess_sandbox::SubprocessSandbox;
2136        use std::path::PathBuf;
2137        use wm_core::sandbox::RunnerSource;
2138        let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2139            wm_core::sandbox::RunnerInfo {
2140                path: PathBuf::from("/opt/mandala-sandbox"),
2141                source: RunnerSource::Env,
2142            },
2143        )));
2144        let pipeline =
2145            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2146        let mut ctx = Context::new(BrainWave::Gamma);
2147        let tool = TestTool::new(
2148            "spawn_tool",
2149            EffectRow {
2150                reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2151                spawns: true,
2152                sandbox: Sandbox::Subprocess,
2153                ..Default::default()
2154            },
2155        )
2156        .with_output(serde_json::json!({"ok": true}));
2157
2158        let out = pipeline
2159            .dispatch(&tool, &mut ctx, Args::default())
2160            .await
2161            .expect("declared spawn tool dispatches");
2162        assert!(ctx.spawn.is_active(), "policy must ride the context");
2163        assert!(ctx.spawn.allow_net(), "network read grants the runner net");
2164        assert_eq!(out["sandbox"]["runner"], "/opt/mandala-sandbox");
2165        assert_eq!(out["sandbox"]["net"], true);
2166        assert_eq!(
2167            out["sandbox"]["envelope"],
2168            wm_core::sandbox::ENVELOPE_SCHEMA
2169        );
2170        assert_eq!(sandbox.status()["dispatches"], 1);
2171        assert_eq!(sandbox.status()["degraded"], 0);
2172    }
2173
2174    #[tokio::test]
2175    async fn pipeline_degrades_loudly_when_runner_missing() {
2176        // No runner resolvable: the declared tool still runs (availability
2177        // first), the dispatch is counted, and no confinement is claimed.
2178        use crate::subprocess_sandbox::SubprocessSandbox;
2179        let sandbox = Arc::new(SubprocessSandbox::with_runner(None));
2180        let pipeline =
2181            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2182        let mut ctx = Context::new(BrainWave::Gamma);
2183        let tool = TestTool::new(
2184            "spawn_tool",
2185            EffectRow {
2186                reads: vec![wm_core::Resource::Process],
2187                spawns: true,
2188                sandbox: Sandbox::Subprocess,
2189                ..Default::default()
2190            },
2191        )
2192        .with_output(serde_json::json!({"ok": true}));
2193
2194        let out = pipeline
2195            .dispatch(&tool, &mut ctx, Args::default())
2196            .await
2197            .expect("degrade keeps availability up");
2198        assert!(!ctx.spawn.is_active());
2199        assert!(
2200            out.get("sandbox").is_none(),
2201            "no runner means no confinement claim"
2202        );
2203        assert_eq!(sandbox.status()["dispatches"], 1);
2204        assert_eq!(sandbox.status()["degraded"], 1);
2205    }
2206
2207    #[tokio::test]
2208    async fn pipeline_surfaces_unmigrated_spawn_tools() {
2209        // A tool that declares raw `spawns` without adopting the
2210        // `Sandbox::Subprocess` contract is counted and warned — the seam
2211        // must not silently pretend coverage it does not have.
2212        use crate::subprocess_sandbox::SubprocessSandbox;
2213        use std::path::PathBuf;
2214        use wm_core::sandbox::RunnerSource;
2215        let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2216            wm_core::sandbox::RunnerInfo {
2217                path: PathBuf::from("/opt/mandala-sandbox"),
2218                source: RunnerSource::Env,
2219            },
2220        )));
2221        let pipeline =
2222            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2223        let mut ctx = Context::new(BrainWave::Gamma);
2224        let tool = TestTool::new(
2225            "legacy_git_tool",
2226            EffectRow {
2227                reads: vec![wm_core::Resource::Process],
2228                spawns: true,
2229                ..Default::default()
2230            },
2231        );
2232
2233        assert!(
2234            pipeline
2235                .dispatch(&tool, &mut ctx, Args::default())
2236                .await
2237                .is_ok()
2238        );
2239        assert!(!ctx.spawn.is_active());
2240        assert_eq!(sandbox.status()["unconfined_spawns"], 1);
2241        assert_eq!(sandbox.status()["dispatches"], 0);
2242    }
2243
2244    #[cfg(unix)]
2245    #[tokio::test]
2246    async fn declared_spawn_executes_through_the_runner_envelope() {
2247        // End-to-end wrap proof: a tool builds its command through
2248        // `ctx.spawn.command(...)`, the fake runner receives the JSON
2249        // envelope on argv, and the envelope carries program/args/net.
2250        use crate::subprocess_sandbox::SubprocessSandbox;
2251        use std::os::unix::fs::PermissionsExt;
2252        use wm_core::sandbox::{RunnerInfo, RunnerSource};
2253
2254        let dir = tempfile::tempdir().expect("tempdir");
2255        let marker = dir.path().join("envelope.json");
2256        let runner = dir.path().join("fake-runner");
2257        std::fs::write(
2258            &runner,
2259            format!(
2260                "#!/bin/sh\nprintf '%s' \"$2\" > '{}'\nexit 0\n",
2261                marker.display()
2262            ),
2263        )
2264        .expect("write fake runner");
2265        std::fs::set_permissions(&runner, std::fs::Permissions::from_mode(0o755))
2266            .expect("chmod fake runner");
2267
2268        struct SpawnProbeTool {
2269            effects: EffectRow,
2270            stats: ToolStats,
2271        }
2272        #[async_trait]
2273        impl Tool for SpawnProbeTool {
2274            fn name(&self) -> &str {
2275                "spawn_probe"
2276            }
2277            fn gana(&self) -> Gana {
2278                Gana::Heart
2279            }
2280            fn effects(&self) -> &EffectRow {
2281                &self.effects
2282            }
2283            async fn call(&self, ctx: &mut Context, _args: Args) -> Result<Output> {
2284                let out = ctx
2285                    .spawn
2286                    .command("printf", &["%s", "hi"])
2287                    .output()
2288                    .map_err(|e| CoreError::Tool(format!("spawn failed: {e}")))?;
2289                if !out.status.success() {
2290                    return Err(CoreError::Tool("wrapped command failed".into()));
2291                }
2292                Ok(serde_json::json!({"ok": true}))
2293            }
2294            fn stats(&self) -> &ToolStats {
2295                &self.stats
2296            }
2297        }
2298
2299        let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(RunnerInfo {
2300            path: runner,
2301            source: RunnerSource::Env,
2302        })));
2303        let pipeline =
2304            DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2305        let mut ctx = Context::new(BrainWave::Gamma);
2306        let tool = SpawnProbeTool {
2307            effects: EffectRow {
2308                reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2309                spawns: true,
2310                sandbox: Sandbox::Subprocess,
2311                ..Default::default()
2312            },
2313            stats: ToolStats::default(),
2314        };
2315        let out = pipeline
2316            .dispatch(&tool, &mut ctx, Args::default())
2317            .await
2318            .expect("wrapped spawn succeeds");
2319        assert_eq!(out["ok"], true);
2320        assert_eq!(out["sandbox"]["net"], true);
2321
2322        let captured = std::fs::read_to_string(&marker).expect("runner captured the envelope");
2323        let envelope: serde_json::Value = serde_json::from_str(&captured).expect("envelope JSON");
2324        assert_eq!(envelope["schema"], wm_core::sandbox::ENVELOPE_SCHEMA);
2325        assert_eq!(envelope["program"], "printf");
2326        assert_eq!(envelope["args"], serde_json::json!(["%s", "hi"]));
2327        assert_eq!(envelope["net"], true);
2328    }
2329
2330    #[tokio::test]
2331    async fn pipeline_secret_scan_warns_without_blocking() {
2332        // P-PROV-5/B(c): the output sampler observes but never governs.
2333        // A credential-shaped successful output dispatches fine and
2334        // records exactly one hit on the attached sampler.
2335        use crate::secret_scan::SecretSampler;
2336        let sampler = Arc::new(SecretSampler::new(1));
2337        let pipeline =
2338            DispatchPipeline::with_defaults().with_secret_scan_option(Some(Arc::clone(&sampler)));
2339        let mut ctx = Context::new(BrainWave::Gamma);
2340        let tool = TestTool::new("key_tool", EffectRow::pure())
2341            .with_output(serde_json::json!({"data": "key=AKIAIOSFODNN7EXAMPLE"}));
2342        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2343        assert!(result.is_ok(), "warn-only scan must never block");
2344        assert_eq!(sampler.stats(), (1, 1, 1));
2345
2346        // Clean outputs scan without hits.
2347        let clean = TestTool::new("clean_tool", EffectRow::pure())
2348            .with_output(serde_json::json!({"results": []}));
2349        assert!(
2350            pipeline
2351                .dispatch(&clean, &mut ctx, Args::default())
2352                .await
2353                .is_ok()
2354        );
2355        assert_eq!(sampler.stats(), (2, 2, 1));
2356    }
2357
2358    #[tokio::test]
2359    async fn pipeline_compartment_sandbox_blocks_read_from_karma() {
2360        let pipeline = DispatchPipeline::with_defaults();
2361        let mut ctx = Context::new(BrainWave::Gamma);
2362        ctx.compartment = Some("sandbox".into());
2363        let tool = TestTool::new(
2364            "read_tool",
2365            EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2366        );
2367
2368        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2369        assert!(result.is_err());
2370        match result {
2371            Err(CoreError::Governance(msg)) => {
2372                assert!(msg.contains("sandbox"));
2373                assert!(msg.contains("karma"));
2374            }
2375            other => panic!("Expected Governance error, got {other:?}"),
2376        }
2377    }
2378
2379    #[tokio::test]
2380    async fn pipeline_compartment_sandbox_allows_write_to_tutorial() {
2381        let pipeline = DispatchPipeline::with_defaults();
2382        let mut ctx = Context::new(BrainWave::Gamma);
2383        ctx.compartment = Some("sandbox".into());
2384        let tool = TestTool::new(
2385            "write_tool",
2386            EffectRow {
2387                writes: vec![wm_core::Resource::Galaxy("tutorial".into())],
2388                ..Default::default()
2389            },
2390        );
2391
2392        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2393        assert!(result.is_ok());
2394    }
2395
2396    #[tokio::test]
2397    async fn pipeline_compartment_sandbox_allows_read_from_research() {
2398        let pipeline = DispatchPipeline::with_defaults();
2399        let mut ctx = Context::new(BrainWave::Gamma);
2400        ctx.compartment = Some("sandbox".into());
2401        let tool = TestTool::new(
2402            "read_tool",
2403            EffectRow::read_only(vec![wm_core::Resource::Galaxy("research".into())]),
2404        );
2405
2406        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2407        assert!(result.is_ok());
2408    }
2409
2410    #[tokio::test]
2411    async fn pipeline_compartment_production_blocks_read_from_karma() {
2412        let pipeline = DispatchPipeline::with_defaults();
2413        let mut ctx = Context::new(BrainWave::Gamma);
2414        ctx.compartment = Some("production".into());
2415        let tool = TestTool::new(
2416            "read_tool",
2417            EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2418        );
2419
2420        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2421        assert!(result.is_err());
2422        match result {
2423            Err(CoreError::Governance(msg)) => {
2424                assert!(msg.contains("production"));
2425                assert!(msg.contains("karma"));
2426            }
2427            other => panic!("Expected Governance error, got {other:?}"),
2428        }
2429    }
2430
2431    #[tokio::test]
2432    async fn pipeline_compartment_production_allows_write_to_codex() {
2433        let pipeline = DispatchPipeline::with_defaults();
2434        let mut ctx = Context::new(BrainWave::Gamma);
2435        ctx.compartment = Some("production".into());
2436        let tool = TestTool::new(
2437            "write_tool",
2438            EffectRow {
2439                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2440                ..Default::default()
2441            },
2442        );
2443
2444        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2445        assert!(result.is_ok());
2446    }
2447
2448    #[tokio::test]
2449    async fn pipeline_compartment_secure_allows_write_to_codex() {
2450        let pipeline = DispatchPipeline::with_defaults();
2451        let mut ctx = Context::new(BrainWave::Gamma);
2452        ctx.compartment = Some("secure".into());
2453        let tool = TestTool::new(
2454            "write_tool",
2455            EffectRow {
2456                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2457                ..Default::default()
2458            },
2459        );
2460
2461        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2462        assert!(result.is_ok());
2463    }
2464
2465    #[tokio::test]
2466    async fn pipeline_compartment_secure_blocks_read_from_karma() {
2467        let pipeline = DispatchPipeline::with_defaults();
2468        let mut ctx = Context::new(BrainWave::Gamma);
2469        ctx.compartment = Some("secure".into());
2470        let tool = TestTool::new(
2471            "read_tool",
2472            EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2473        );
2474
2475        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2476        assert!(result.is_err());
2477        match result {
2478            Err(CoreError::Governance(msg)) => {
2479                assert!(msg.contains("secure"));
2480                assert!(msg.contains("karma"));
2481            }
2482            other => panic!("Expected Governance error, got {other:?}"),
2483        }
2484    }
2485
2486    // ── Resource rules (Yama) pipeline tests ──────────────────────────
2487
2488    fn rules_with(max_writes: u32, max_repeats: u32) -> Arc<ResourceRules> {
2489        Arc::new(ResourceRules::new(ResourceRulesConfig {
2490            max_writes_per_minute: max_writes,
2491            max_spawns_per_minute: 100,
2492            max_network_per_minute: 100,
2493            novelty_window: 50,
2494            max_repeats,
2495            require_human_review: false,
2496        }))
2497    }
2498
2499    #[tokio::test]
2500    async fn pipeline_resource_rules_budget_exceeding_write_refused() {
2501        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(2, 1000));
2502        let mut ctx = Context::new(BrainWave::Gamma);
2503        let tool = TestTool::new(
2504            "write_tool",
2505            EffectRow {
2506                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2507                ..Default::default()
2508            },
2509        );
2510
2511        assert!(
2512            pipeline
2513                .dispatch(&tool, &mut ctx, Args::default())
2514                .await
2515                .is_ok(),
2516            "first write within budget"
2517        );
2518        assert!(
2519            pipeline
2520                .dispatch(&tool, &mut ctx, Args::default())
2521                .await
2522                .is_ok(),
2523            "second write within budget"
2524        );
2525        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2526        assert!(result.is_err(), "third write must exceed the budget");
2527        match result {
2528            Err(CoreError::Governance(msg)) => {
2529                assert!(msg.contains("resource rules"), "got: {msg}");
2530                assert!(msg.contains("writes"), "got: {msg}");
2531            }
2532            other => panic!("Expected Governance error, got {other:?}"),
2533        }
2534    }
2535
2536    #[tokio::test]
2537    async fn pipeline_resource_rules_novelty_flag_reaches_response() {
2538        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(1000, 1));
2539        let mut ctx = Context::new(BrainWave::Gamma);
2540        let tool = TestTool::new("read_tool", EffectRow::pure())
2541            .with_output(serde_json::json!({"status": "ok"}));
2542
2543        let first = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2544        assert!(first.is_ok());
2545        assert!(
2546            first.unwrap().get("resource_flags").is_none(),
2547            "first call is novel — no flag"
2548        );
2549
2550        let second = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2551        let output = second.expect("repeated call must still succeed (flag, not block)");
2552        let flags = output
2553            .get("resource_flags")
2554            .and_then(|f| f.as_array())
2555            .expect("novelty flag must reach the response");
2556        assert_eq!(flags.len(), 1);
2557        assert!(flags[0].as_str().unwrap().contains("not novel"));
2558    }
2559
2560    #[tokio::test]
2561    async fn pipeline_resource_rules_blocks_unapproved_autonomous() {
2562        let rules = Arc::new(ResourceRules::default());
2563        rules.set_user_initiated(false);
2564        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2565        let mut ctx = Context::new(BrainWave::Gamma);
2566        let tool = TestTool::new(
2567            "memory.consolidate",
2568            EffectRow {
2569                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2570                ..Default::default()
2571            },
2572        );
2573
2574        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2575        assert!(result.is_err());
2576        match result {
2577            Err(CoreError::Governance(msg)) => {
2578                assert!(msg.contains("human review"), "got: {msg}");
2579            }
2580            other => panic!("Expected Governance error, got {other:?}"),
2581        }
2582    }
2583
2584    #[tokio::test]
2585    async fn pipeline_resource_rules_allows_approved_autonomous() {
2586        let rules = Arc::new(ResourceRules::default());
2587        rules.set_user_initiated(false);
2588        rules.set_human_approved(true);
2589        let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2590        let mut ctx = Context::new(BrainWave::Gamma);
2591        let tool = TestTool::new(
2592            "memory.consolidate",
2593            EffectRow {
2594                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2595                ..Default::default()
2596            },
2597        );
2598
2599        let result = pipeline
2600            .dispatch(
2601                &tool,
2602                &mut ctx,
2603                serde_json::json!({"purpose": "consolidate codex"}),
2604            )
2605            .await;
2606        assert!(result.is_ok());
2607    }
2608
2609    #[tokio::test]
2610    async fn pipeline_resource_rules_user_initiated_writes_allowed_by_default() {
2611        // Default rules: user-initiated actions are not gated by human review.
2612        let pipeline = DispatchPipeline::with_defaults()
2613            .with_resource_rules(Arc::new(ResourceRules::default()));
2614        let mut ctx = Context::new(BrainWave::Gamma);
2615        let tool = TestTool::new(
2616            "write_tool",
2617            EffectRow {
2618                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2619                ..Default::default()
2620            },
2621        );
2622
2623        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2624        assert!(result.is_ok());
2625    }
2626
2627    // ── Runtime Satya (fabrication) tests ─────────────────────────────
2628
2629    #[tokio::test]
2630    async fn pipeline_runtime_satya_blocks_citta_write_without_read() {
2631        let pipeline = DispatchPipeline::with_defaults();
2632        let mut ctx = Context::new(BrainWave::Gamma);
2633        let tool = TestTool::new(
2634            "memory.create",
2635            EffectRow {
2636                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2637                ..Default::default()
2638            },
2639        );
2640
2641        let result = pipeline
2642            .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2643            .await;
2644        assert!(result.is_err());
2645        match result {
2646            Err(CoreError::Governance(msg)) => {
2647                assert!(msg.contains("VIOLATION_SATYA"), "got: {msg}");
2648            }
2649            other => panic!("Expected Governance error, got {other:?}"),
2650        }
2651    }
2652
2653    #[tokio::test]
2654    async fn pipeline_runtime_satya_allows_citta_write_with_read_evidence() {
2655        let pipeline = DispatchPipeline::with_defaults();
2656        let mut ctx = Context::new(BrainWave::Gamma);
2657        let tool = TestTool::new(
2658            "consolidate_tool",
2659            EffectRow {
2660                reads: vec![wm_core::Resource::Galaxy("citta".into())],
2661                writes: vec![wm_core::Resource::Galaxy("citta".into())],
2662                ..Default::default()
2663            },
2664        );
2665
2666        let result = pipeline
2667            .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2668            .await;
2669        assert!(result.is_ok());
2670    }
2671
2672    #[tokio::test]
2673    async fn pipeline_runtime_satya_allows_non_citta_runtime_galaxy() {
2674        let pipeline = DispatchPipeline::with_defaults();
2675        let mut ctx = Context::new(BrainWave::Gamma);
2676        let tool = TestTool::new(
2677            "memory.create",
2678            EffectRow {
2679                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2680                ..Default::default()
2681            },
2682        );
2683
2684        let result = pipeline
2685            .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "research"}))
2686            .await;
2687        assert!(result.is_ok());
2688    }
2689
2690    // ── Write-audit journal pipeline tests ────────────────────────────
2691
2692    #[tokio::test]
2693    async fn pipeline_write_audit_detects_misdeclaring_tool() {
2694        let tmp = tempfile::tempdir().unwrap();
2695        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2696        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2697        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2698        let mut ctx = Context::new(BrainWave::Gamma);
2699
2700        // Declares a pure effect row but actually writes to the store.
2701        let tool = TestTool::new("sneaky_tool", EffectRow::pure()).with_store(store);
2702
2703        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2704        assert!(result.is_ok());
2705
2706        let mis = journal.misdeclarations().unwrap();
2707        assert!(!mis.is_empty(), "misdeclaring tool must be detected");
2708        assert_eq!(mis.last().unwrap().tool, "sneaky_tool");
2709        assert!(mis.last().unwrap().undeclared_mutation());
2710    }
2711
2712    #[tokio::test]
2713    async fn pipeline_write_audit_skips_meta_router() {
2714        let tmp = tempfile::tempdir().unwrap();
2715        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2716        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2717        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2718        let mut ctx = Context::new(BrainWave::Gamma);
2719
2720        // The meta-router mutates through nested dispatches (which journal
2721        // the real tool); its own entry must never be flagged as an
2722        // undeclared mutation (first-run feedback regression, 2026-09-13).
2723        let tool = TestTool::new("wm", EffectRow::pure()).with_store(store);
2724        let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2725        assert!(result.is_ok());
2726
2727        let mis = journal.misdeclarations().unwrap();
2728        assert!(
2729            mis.iter().all(|m| m.tool != "wm"),
2730            "meta router must not appear as a misdeclaration: {mis:?}"
2731        );
2732    }
2733
2734    #[tokio::test]
2735    async fn pipeline_write_audit_records_declared_writes_with_identity() {
2736        let tmp = tempfile::tempdir().unwrap();
2737        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2738        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2739        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2740        let mut ctx = Context::new(BrainWave::Gamma);
2741
2742        let tool = TestTool::new(
2743            "honest_tool",
2744            EffectRow {
2745                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2746                ..Default::default()
2747            },
2748        )
2749        .with_store(store);
2750
2751        let args = serde_json::json!({"id": "abc-123", "content_hash": "hash-xyz"});
2752        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2753        assert!(result.is_ok());
2754
2755        let entries = journal.scan_entries().unwrap();
2756        assert_eq!(entries.len(), 1);
2757        let entry = &entries[0];
2758        assert!(entry.declared_writes);
2759        assert!(entry.store_write_delta >= 1);
2760        assert_eq!(entry.memory_id.as_deref(), Some("abc-123"));
2761        assert_eq!(entry.content_hash.as_deref(), Some("hash-xyz"));
2762        assert!(journal.misdeclarations().unwrap().is_empty());
2763    }
2764
2765    #[tokio::test]
2766    async fn pipeline_write_audit_captures_actor_identity() {
2767        // S11b: the journal answers "which agent did this" — identity rides
2768        // the Context (_meta-derived) into every entry.
2769        let tmp = tempfile::tempdir().unwrap();
2770        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2771        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2772        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2773        let mut ctx = Context::new(BrainWave::Gamma);
2774        ctx.session_id = Some(uuid::Uuid::nil());
2775        ctx.user_id = Some("agent-b".to_string());
2776        ctx.compartment = Some("production".to_string());
2777
2778        let tool = TestTool::new(
2779            "honest_tool",
2780            EffectRow {
2781                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2782                ..Default::default()
2783            },
2784        )
2785        .with_store(store);
2786
2787        let result = pipeline
2788            .dispatch(&tool, &mut ctx, serde_json::json!({"id": "abc-123"}))
2789            .await;
2790        assert!(result.is_ok());
2791
2792        let entries = journal.scan_entries().unwrap();
2793        assert_eq!(entries.len(), 1);
2794        let entry = &entries[0];
2795        assert_eq!(
2796            entry.actor_session.as_deref(),
2797            Some(uuid::Uuid::nil().to_string().as_str())
2798        );
2799        assert_eq!(entry.actor_user.as_deref(), Some("agent-b"));
2800        assert_eq!(entry.actor_compartment.as_deref(), Some("production"));
2801    }
2802
2803    #[tokio::test]
2804    async fn pipeline_write_audit_read_dispatch_not_flagged_after_external_writes() {
2805        // The 2026-08-28 restore-drill false positive: a parallel session's
2806        // writes land before (or while) an honest read-only dispatch runs;
2807        // the old since-last-entry attribution flagged the read tool with
2808        // the other dispatch's write count. Per-dispatch baselines close it.
2809        let tmp = tempfile::tempdir().unwrap();
2810        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2811        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2812        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2813        let mut ctx = Context::new(BrainWave::Gamma);
2814
2815        // The other session's traffic lands before this dispatch starts.
2816        for i in 0..3 {
2817            let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("other session {i}"));
2818            store.put(wm_core::Galaxy::Codex, &mem).unwrap();
2819        }
2820
2821        let read_tool = TestTool::new("memory.search", EffectRow::pure());
2822        let result = pipeline
2823            .dispatch(&read_tool, &mut ctx, Args::default())
2824            .await;
2825        assert!(result.is_ok());
2826
2827        let mis = journal.misdeclarations().unwrap();
2828        assert!(
2829            mis.is_empty(),
2830            "read-only dispatch must not inherit the other session's writes: {mis:?}"
2831        );
2832        let entries = journal.scan_entries().unwrap();
2833        assert_eq!(entries.last().unwrap().store_write_delta, 0);
2834    }
2835
2836    // ── Firebreak (P1.4 forbidden-command veto + P1.6 bulk-scope law) ──
2837
2838    #[tokio::test]
2839    async fn pipeline_firebreak_forbidden_blocks_even_with_confirm() {
2840        let pipeline = DispatchPipeline::with_defaults();
2841        let mut ctx = Context::new(BrainWave::Gamma);
2842        let tool = TestTool::new(
2843            "destructive_tool",
2844            EffectRow {
2845                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2846                destructive: true,
2847                ..Default::default()
2848            },
2849        );
2850
2851        let result = pipeline
2852            .dispatch(
2853                &tool,
2854                &mut ctx,
2855                serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
2856            )
2857            .await;
2858        match result {
2859            Err(CoreError::Governance(msg)) => {
2860                assert!(msg.contains("FORBIDDEN"), "got: {msg}");
2861                assert!(msg.contains("never allowed"), "got: {msg}");
2862            }
2863            other => panic!("Expected Governance error, got {other:?}"),
2864        }
2865    }
2866
2867    #[tokio::test]
2868    async fn pipeline_firebreak_scope_law_blocks_unscoped_destructive() {
2869        let pipeline = DispatchPipeline::with_defaults();
2870        let mut ctx = Context::new(BrainWave::Gamma);
2871        // Named like the real tool so the scope registry entry applies.
2872        let tool = TestTool::new(
2873            "memory.delete",
2874            EffectRow {
2875                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2876                destructive: true,
2877                ..Default::default()
2878            },
2879        );
2880
2881        let result = pipeline
2882            .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
2883            .await;
2884        match result {
2885            Err(CoreError::Governance(msg)) => {
2886                assert!(msg.contains("no explicit scope"), "got: {msg}");
2887                assert!(msg.contains("id"), "names the scope field: {msg}");
2888            }
2889            other => panic!("Expected Governance error, got {other:?}"),
2890        }
2891    }
2892
2893    #[tokio::test]
2894    async fn pipeline_firebreak_scope_law_allows_scoped_destructive() {
2895        let pipeline = DispatchPipeline::with_defaults();
2896        let mut ctx = Context::new(BrainWave::Gamma);
2897        let tool = TestTool::new(
2898            "memory.delete",
2899            EffectRow {
2900                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2901                destructive: true,
2902                ..Default::default()
2903            },
2904        );
2905
2906        let result = pipeline
2907            .dispatch(
2908                &tool,
2909                &mut ctx,
2910                serde_json::json!({"confirm": true, "id": "0f0e0d0c-0000-0000-0000-000000000000"}),
2911            )
2912            .await;
2913        assert!(result.is_ok());
2914    }
2915
2916    #[tokio::test]
2917    async fn pipeline_firebreak_caution_disclosed_in_response() {
2918        let pipeline = DispatchPipeline::with_defaults();
2919        let mut ctx = Context::new(BrainWave::Gamma);
2920        let tool = TestTool::new(
2921            "galaxy.transfer",
2922            EffectRow {
2923                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2924                destructive: true,
2925                ..Default::default()
2926            },
2927        )
2928        .with_output(serde_json::json!({"status": "success"}));
2929
2930        let result = pipeline
2931            .dispatch(
2932                &tool,
2933                &mut ctx,
2934                serde_json::json!({"confirm": true, "from_galaxy": "codex", "note": "mv old new"}),
2935            )
2936            .await;
2937        let output = result.expect("caution must not block");
2938        let advisories = output
2939            .get("firebreak")
2940            .and_then(|f| f.get("advisories"))
2941            .and_then(|a| a.as_array())
2942            .expect("advisories must reach the response");
2943        assert_eq!(advisories.len(), 1);
2944    }
2945
2946    #[tokio::test]
2947    async fn pipeline_firebreak_dangerous_escalates_off_confirm_gate() {
2948        // A spawn-class seam tool that is NOT destructive-flagged: the
2949        // confirm gate (4b) never fires, but a dangerous payload in args
2950        // must still demand explicit confirm — the confirm-gate hardening.
2951        let pipeline = DispatchPipeline::with_defaults();
2952        let mut ctx = Context::new(BrainWave::Gamma);
2953        let tool = TestTool::new(
2954            "spawn_tool",
2955            EffectRow {
2956                spawns: true,
2957                ..Default::default()
2958            },
2959        );
2960
2961        let blocked = pipeline
2962            .dispatch(
2963                &tool,
2964                &mut ctx,
2965                serde_json::json!({"cmd": "sudo rm -r /tmp/build"}),
2966            )
2967            .await;
2968        match blocked {
2969            Err(CoreError::Governance(msg)) => {
2970                assert!(msg.contains("dangerous"), "got: {msg}");
2971                assert!(msg.contains("confirm"), "got: {msg}");
2972            }
2973            other => panic!("Expected Governance error, got {other:?}"),
2974        }
2975
2976        let allowed = pipeline
2977            .dispatch(
2978                &tool,
2979                &mut ctx,
2980                serde_json::json!({"cmd": "sudo rm -r /tmp/build", "confirm": true}),
2981            )
2982            .await;
2983        assert!(allowed.is_ok());
2984    }
2985
2986    #[tokio::test]
2987    async fn pipeline_firebreak_never_scans_prose() {
2988        // The seam is irreversible dispatches — a memory-create-style tool
2989        // recording an incident note quoting a forbidden command must pass.
2990        let pipeline = DispatchPipeline::with_defaults();
2991        let mut ctx = Context::new(BrainWave::Gamma);
2992        let tool = TestTool::new(
2993            "memory.create",
2994            EffectRow {
2995                writes: vec![wm_core::Resource::Galaxy("codex".into())],
2996                ..Default::default()
2997            },
2998        );
2999
3000        let result = pipeline
3001            .dispatch(
3002                &tool,
3003                &mut ctx,
3004                serde_json::json!({"content": "incident: operator ran rm -rf / on the store"}),
3005            )
3006            .await;
3007        assert!(result.is_ok(), "prose is never vetoed");
3008    }
3009
3010    #[tokio::test]
3011    async fn pipeline_firebreak_disarmable_per_pipeline() {
3012        let pipeline = DispatchPipeline::with_defaults()
3013            .with_firebreak_option(None::<Arc<wm_governance::Firebreak>>);
3014        let mut ctx = Context::new(BrainWave::Gamma);
3015        let tool = TestTool::new(
3016            "destructive_tool",
3017            EffectRow {
3018                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3019                destructive: true,
3020                ..Default::default()
3021            },
3022        );
3023
3024        // Confirm gate still fires (it is outside the firebreak).
3025        let result = pipeline
3026            .dispatch(&tool, &mut ctx, serde_json::json!({}))
3027            .await;
3028        assert!(result.is_err());
3029
3030        // But the forbidden-command veto is gone.
3031        let result = pipeline
3032            .dispatch(
3033                &tool,
3034                &mut ctx,
3035                serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
3036            )
3037            .await;
3038        assert!(result.is_ok(), "disarmed pipeline must not veto");
3039    }
3040
3041    #[tokio::test]
3042    async fn pipeline_write_audit_records_destructive_confirm() {
3043        // The delete-confirm audit (P1.6): a destructive dispatch's journal
3044        // entry answers "was this confirmed?".
3045        let tmp = tempfile::tempdir().unwrap();
3046        let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
3047        let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
3048        let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
3049        let mut ctx = Context::new(BrainWave::Gamma);
3050
3051        let tool = TestTool::new(
3052            "memory.delete",
3053            EffectRow {
3054                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3055                destructive: true,
3056                ..Default::default()
3057            },
3058        )
3059        .with_store(store);
3060
3061        let result = pipeline
3062            .dispatch(
3063                &tool,
3064                &mut ctx,
3065                serde_json::json!({"confirm": true, "id": "abc-123"}),
3066            )
3067            .await;
3068        assert!(result.is_ok());
3069
3070        let entries = journal.scan_entries().unwrap();
3071        assert_eq!(entries.len(), 1);
3072        assert_eq!(
3073            entries[0].confirmed,
3074            Some(true),
3075            "destructive entry must record the confirm"
3076        );
3077    }
3078
3079    // ── Runtime galaxy argument enforcement tests ──────────────────────
3080
3081    #[tokio::test]
3082    async fn pipeline_compartment_production_blocks_runtime_galaxy_write_bypass() {
3083        // Tool declares writes to "codex" (allowed for production) but runtime
3084        // galaxy arg is "karma" — production should be blocked from writing karma.
3085        let pipeline = DispatchPipeline::with_defaults();
3086        let mut ctx = Context::new(BrainWave::Gamma);
3087        ctx.compartment = Some("production".into());
3088        let tool = TestTool::new(
3089            "memory_update",
3090            EffectRow {
3091                writes: vec![wm_core::Resource::Galaxy("codex".into())],
3092                ..Default::default()
3093            },
3094        );
3095
3096        let args = serde_json::json!({"galaxy": "karma"});
3097        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3098        assert!(result.is_err());
3099        match result {
3100            Err(CoreError::Governance(msg)) => {
3101                assert!(msg.contains("production"));
3102                assert!(msg.contains("karma"));
3103                assert!(msg.contains("runtime"));
3104            }
3105            other => panic!("Expected Governance error, got {other:?}"),
3106        }
3107    }
3108
3109    #[tokio::test]
3110    async fn pipeline_compartment_production_blocks_runtime_galaxy_read_bypass() {
3111        // Tool declares reads from "codex" (allowed for production) but runtime
3112        // galaxy arg is "karma" — production should be blocked from reading karma.
3113        let pipeline = DispatchPipeline::with_defaults();
3114        let mut ctx = Context::new(BrainWave::Gamma);
3115        ctx.compartment = Some("production".into());
3116        let tool = TestTool::new(
3117            "memory_read",
3118            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3119        );
3120
3121        let args = serde_json::json!({"galaxy": "karma"});
3122        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3123        assert!(result.is_err());
3124        match result {
3125            Err(CoreError::Governance(msg)) => {
3126                assert!(msg.contains("production"));
3127                assert!(msg.contains("karma"));
3128                assert!(msg.contains("runtime"));
3129            }
3130            other => panic!("Expected Governance error, got {other:?}"),
3131        }
3132    }
3133
3134    #[tokio::test]
3135    async fn pipeline_compartment_production_allows_runtime_galaxy_same_as_declared() {
3136        // Tool declares reads from "codex" and runtime galaxy arg is also "codex"
3137        // — production should allow this (no duplicate check needed).
3138        let pipeline = DispatchPipeline::with_defaults();
3139        let mut ctx = Context::new(BrainWave::Gamma);
3140        ctx.compartment = Some("production".into());
3141        let tool = TestTool::new(
3142            "memory_read",
3143            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3144        );
3145
3146        let args = serde_json::json!({"galaxy": "codex"});
3147        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3148        assert!(result.is_ok());
3149    }
3150
3151    #[tokio::test]
3152    async fn pipeline_compartment_no_restriction_allows_runtime_galaxy() {
3153        // No compartment — runtime galaxy arg should be allowed regardless.
3154        let pipeline = DispatchPipeline::with_defaults();
3155        let mut ctx = Context::new(BrainWave::Gamma);
3156        let tool = TestTool::new(
3157            "memory_read",
3158            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3159        );
3160
3161        let args = serde_json::json!({"galaxy": "karma"});
3162        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3163        assert!(result.is_ok());
3164    }
3165
3166    #[tokio::test]
3167    async fn pipeline_compartment_production_allows_runtime_memory_galaxy() {
3168        // Production compartment — runtime galaxy arg "codex" should be allowed
3169        // since production can access all memory galaxies.
3170        let pipeline = DispatchPipeline::with_defaults();
3171        let mut ctx = Context::new(BrainWave::Gamma);
3172        ctx.compartment = Some("production".into());
3173        let tool = TestTool::new(
3174            "memory_read",
3175            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3176        );
3177
3178        let args = serde_json::json!({"galaxy": "research"});
3179        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3180        assert!(result.is_ok());
3181    }
3182
3183    #[tokio::test]
3184    async fn pipeline_compartment_production_blocks_runtime_system_galaxy() {
3185        // Production compartment — runtime galaxy arg "karma" should be blocked
3186        // since production can't access system galaxies.
3187        let pipeline = DispatchPipeline::with_defaults();
3188        let mut ctx = Context::new(BrainWave::Gamma);
3189        ctx.compartment = Some("production".into());
3190        let tool = TestTool::new(
3191            "memory_read",
3192            EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3193        );
3194
3195        let args = serde_json::json!({"galaxy": "karma"});
3196        let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3197        assert!(result.is_err());
3198        match result {
3199            Err(CoreError::Governance(msg)) => {
3200                assert!(msg.contains("production"));
3201                assert!(msg.contains("karma"));
3202                assert!(msg.contains("runtime"));
3203            }
3204            other => panic!("Expected Governance error, got {other:?}"),
3205        }
3206    }
3207
3208    #[tokio::test]
3209    async fn benchmark_pipeline_overhead() {
3210        let pipeline = DispatchPipeline::with_defaults();
3211        let tool = TestTool::new("bench_tool", EffectRow::pure());
3212        let args = Args::default();
3213
3214        // Warm up
3215        for _ in 0..100 {
3216            let mut ctx = Context::new(BrainWave::Gamma);
3217            let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3218        }
3219
3220        // Measure pipeline dispatch
3221        let n = 10_000;
3222        let start = std::time::Instant::now();
3223        for _ in 0..n {
3224            let mut ctx = Context::new(BrainWave::Gamma);
3225            let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3226        }
3227        let pipeline_ns = start.elapsed().as_nanos() / n;
3228
3229        // Measure direct tool call (no pipeline)
3230        let start = std::time::Instant::now();
3231        for _ in 0..n {
3232            let mut ctx = Context::new(BrainWave::Gamma);
3233            let _ = tool.call(&mut ctx, args.clone()).await;
3234        }
3235        let direct_ns = start.elapsed().as_nanos() / n;
3236
3237        let overhead_ns = pipeline_ns.saturating_sub(direct_ns);
3238        println!(
3239            "\n  Pipeline: {pipeline_ns} ns/call | Direct: {direct_ns} ns/call | Overhead: {overhead_ns} ns/call"
3240        );
3241
3242        // Pipeline overhead should be under 5µs per call (5000 ns) in release builds.
3243        // Debug builds have unoptimized async/await overhead, so we only assert
3244        // when compiled with optimizations.
3245        #[cfg(not(debug_assertions))]
3246        assert!(
3247            overhead_ns < 5_000,
3248            "Pipeline overhead {overhead_ns} ns/call exceeds 5µs budget"
3249        );
3250    }
3251}