Skip to main content

wm_dispatch/
pipeline.rs

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