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