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