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