1#[cfg(test)]
22use async_trait::async_trait;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use wm_core::{Args, Context, CoreError, Output, Result, Tool};
26
27use crate::capability_gate::{CapabilityGateMode, GateOutcome};
28use crate::circuit_breaker::CircuitBreakerRegistry;
29use crate::rate_limiter::RateLimiter;
30use wm_governance::{
31 ActionVerdict, DharmaGate, FirebreakOutcome, KarmaLedger, ResourceRules, ResourceVerdict,
32};
33
34pub const DEFAULT_DISPATCH_TIMEOUT: Duration = Duration::from_secs(300);
40
41fn hash_args(args: &Args) -> u64 {
44 use std::hash::Hasher;
45 let bytes = serde_json::to_vec(args).unwrap_or_default();
46 let mut hasher = ahash::AHasher::default();
47 hasher.write(&bytes);
48 hasher.finish()
49}
50
51fn first_str(v: &serde_json::Value, keys: &[&str]) -> Option<String> {
53 keys.iter().find_map(|k| {
54 v.get(*k)
55 .and_then(serde_json::Value::as_str)
56 .map(str::to_string)
57 })
58}
59
60#[allow(clippy::too_many_arguments)]
69fn record_write_audit(
70 journal: &wm_governance::WriteAuditJournal,
71 store_write_baseline: u64,
72 tool: &str,
73 actor: wm_governance::ActorIdentity,
74 declared_writes: bool,
75 args_memory_id: Option<&str>,
76 args_content_hash: Option<&str>,
77 args_digest: Option<String>,
78 output: &serde_json::Value,
79 success: bool,
80 confirm_gated: Option<bool>,
81) {
82 if tool == "wm" {
88 return;
89 }
90 let reported_writes = output
91 .get("writes")
92 .and_then(|w| w.as_array())
93 .map_or(0, |a| a.len() as u32);
94 let memory_id = first_str(output, &["id", "memory_id", "memory"])
95 .or_else(|| args_memory_id.map(str::to_string));
96 let content_hash = first_str(output, &["content_hash", "hash", "sha256"])
97 .or_else(|| args_content_hash.map(str::to_string));
98 let result = match confirm_gated {
99 Some(confirmed) => journal.record_since_confirmed(
100 store_write_baseline,
101 tool,
102 actor,
103 memory_id.as_deref(),
104 content_hash.as_deref(),
105 declared_writes,
106 reported_writes,
107 success,
108 confirmed,
109 args_digest,
110 ),
111 None => journal.record_since(
112 store_write_baseline,
113 tool,
114 actor,
115 memory_id.as_deref(),
116 content_hash.as_deref(),
117 declared_writes,
118 reported_writes,
119 success,
120 args_digest,
121 ),
122 };
123 if let Err(e) = result {
124 tracing::warn!(error = %e, "Write-audit journal record failed");
125 }
126}
127
128pub struct DispatchPipeline {
132 rate_limiter: Arc<RateLimiter>,
133 circuit_breakers: Arc<CircuitBreakerRegistry>,
134 dharma_gate: Arc<DharmaGate>,
135 karma_ledger: Option<Arc<KarmaLedger>>,
136 resource_rules: Option<Arc<ResourceRules>>,
139 write_gate: Option<Arc<crate::write_gate::WriteGate>>,
142 write_audit: Option<Arc<wm_governance::WriteAuditJournal>>,
145 secret_scan: Option<crate::secret_scan::SharedSampler>,
148 sandbox_exec: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
152 subprocess_sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
158 flight_recorder: Option<Arc<crate::flight::FlightRecorder>>,
162 firebreak: Option<Arc<wm_governance::Firebreak>>,
166 capability_mode: CapabilityGateMode,
171 gana_registry: Option<Arc<std::sync::Mutex<wm_core::GanaRegistry>>>,
173 dispatch_timeout: Option<Duration>,
177}
178
179impl DispatchPipeline {
180 pub fn new(
185 rate_limiter: Arc<RateLimiter>,
186 circuit_breakers: Arc<CircuitBreakerRegistry>,
187 dharma_gate: Arc<DharmaGate>,
188 karma_ledger: Option<Arc<KarmaLedger>>,
189 ) -> Self {
190 Self {
191 rate_limiter,
192 circuit_breakers,
193 dharma_gate,
194 karma_ledger,
195 resource_rules: None,
196 write_gate: None,
197 write_audit: None,
198 flight_recorder: None,
199 secret_scan: Some(Arc::new(crate::secret_scan::SecretSampler::from_env())),
204 sandbox_exec: None,
208 subprocess_sandbox: None,
212 firebreak: Some(Arc::new(wm_governance::Firebreak::promoted())),
218 capability_mode: CapabilityGateMode::from_env(),
219 gana_registry: None,
220 dispatch_timeout: None,
221 }
222 }
223
224 #[must_use]
229 pub fn timeout_from_env() -> Option<Duration> {
230 match std::env::var("WM_DISPATCH_TIMEOUT_MS") {
231 Ok(v) => match v.trim().parse::<u64>() {
232 Ok(0) => None,
233 Ok(ms) => Some(Duration::from_millis(ms)),
234 Err(_) => {
235 tracing::warn!(
236 value = %v,
237 "WM_DISPATCH_TIMEOUT_MS is not a valid millisecond count — using default"
238 );
239 Some(DEFAULT_DISPATCH_TIMEOUT)
240 }
241 },
242 Err(_) => Some(DEFAULT_DISPATCH_TIMEOUT),
243 }
244 }
245
246 #[must_use]
248 pub const fn with_dispatch_timeout(mut self, timeout: Option<Duration>) -> Self {
249 self.dispatch_timeout = timeout;
250 self
251 }
252
253 #[must_use]
255 pub fn with_defaults() -> Self {
256 Self::new(
257 Arc::new(RateLimiter::default()),
258 Arc::new(CircuitBreakerRegistry::default()),
259 Arc::new(DharmaGate::default()),
260 None,
261 )
262 }
263
264 #[must_use]
266 pub const fn with_capability_mode(mut self, mode: CapabilityGateMode) -> Self {
267 self.capability_mode = mode;
268 self
269 }
270
271 #[must_use]
273 pub fn with_gana_registry(
274 mut self,
275 registry: Arc<std::sync::Mutex<wm_core::GanaRegistry>>,
276 ) -> Self {
277 self.gana_registry = Some(registry);
278 self
279 }
280
281 #[must_use]
283 pub fn with_resource_rules(mut self, rules: Arc<ResourceRules>) -> Self {
284 self.resource_rules = Some(rules);
285 self
286 }
287
288 #[must_use]
292 pub fn with_write_gate(mut self, gate: Arc<crate::write_gate::WriteGate>) -> Self {
293 self.write_gate = Some(gate);
294 self
295 }
296
297 #[must_use]
300 pub fn with_write_audit(mut self, journal: Arc<wm_governance::WriteAuditJournal>) -> Self {
301 self.write_audit = Some(journal);
302 self
303 }
304
305 #[must_use]
309 pub fn with_flight_recorder(
310 mut self,
311 recorder: Option<Arc<crate::flight::FlightRecorder>>,
312 ) -> Self {
313 self.flight_recorder = recorder;
314 self
315 }
316
317 #[must_use]
320 pub fn with_secret_scan_option(
321 mut self,
322 scanner: Option<crate::secret_scan::SharedSampler>,
323 ) -> Self {
324 self.secret_scan = scanner;
325 self
326 }
327
328 #[must_use]
330 pub fn secret_scan(&self) -> Option<&crate::secret_scan::SecretSampler> {
331 self.secret_scan.as_deref()
332 }
333
334 #[must_use]
338 pub fn with_sandbox_executor(
339 mut self,
340 executor: Option<Arc<crate::sandbox_exec::ScopedSandboxExecutor>>,
341 ) -> Self {
342 self.sandbox_exec = executor;
343 self
344 }
345
346 #[must_use]
348 pub fn sandbox_executor(&self) -> Option<&crate::sandbox_exec::ScopedSandboxExecutor> {
349 self.sandbox_exec.as_deref()
350 }
351
352 #[must_use]
356 pub fn with_subprocess_sandbox(
357 mut self,
358 sandbox: Option<Arc<crate::subprocess_sandbox::SubprocessSandbox>>,
359 ) -> Self {
360 self.subprocess_sandbox = sandbox;
361 self
362 }
363
364 #[must_use]
366 pub fn subprocess_sandbox(&self) -> Option<&crate::subprocess_sandbox::SubprocessSandbox> {
367 self.subprocess_sandbox.as_deref()
368 }
369
370 #[must_use]
373 pub fn with_firebreak(mut self, firebreak: Arc<wm_governance::Firebreak>) -> Self {
374 self.firebreak = Some(firebreak);
375 self
376 }
377
378 #[must_use]
383 pub fn with_firebreak_option(
384 mut self,
385 firebreak: Option<Arc<wm_governance::Firebreak>>,
386 ) -> Self {
387 self.firebreak = firebreak;
388 self
389 }
390
391 #[must_use]
393 pub fn firebreak(&self) -> Option<&wm_governance::Firebreak> {
394 self.firebreak.as_deref()
395 }
396
397 #[must_use]
400 pub fn with_write_audit_option(
401 mut self,
402 journal: Option<Arc<wm_governance::WriteAuditJournal>>,
403 ) -> Self {
404 self.write_audit = journal;
405 self
406 }
407
408 #[must_use]
410 pub fn resource_rules(&self) -> Option<&ResourceRules> {
411 self.resource_rules.as_deref()
412 }
413
414 #[must_use]
416 pub fn write_audit(&self) -> Option<&wm_governance::WriteAuditJournal> {
417 self.write_audit.as_deref()
418 }
419
420 pub async fn dispatch(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
422 let start = Instant::now();
423 let mut args = args;
424
425 let confirmed = args
432 .get("confirm")
433 .and_then(serde_json::Value::as_bool)
434 .unwrap_or(false);
435 ctx.explicit_confirm = confirmed;
436 if !tool.effects().is_available_in(ctx.brain_wave) && !confirmed {
437 return Err(CoreError::Governance(format!(
438 "tool '{}' not available in {:?} brain-wave state",
439 tool.name(),
440 ctx.brain_wave
441 )));
442 }
443
444 const COHERENCE_THRESHOLD: f32 = 0.3;
446 if !tool.effects().writes.is_empty() && ctx.citta_coherence < COHERENCE_THRESHOLD {
447 return Err(CoreError::Governance(format!(
448 "tool '{}' requires write access but citta coherence is {:.2} (minimum {:.2})",
449 tool.name(),
450 ctx.citta_coherence,
451 COHERENCE_THRESHOLD
452 )));
453 }
454
455 if ctx.readonly && !tool.effects().writes.is_empty() {
459 return Err(CoreError::Governance(format!(
460 "server is read-only: tool '{}' requires write access",
461 tool.name()
462 )));
463 }
464
465 const CONFIDENCE_THRESHOLD: f32 = 0.5;
467 if ctx.self_model_confidence < CONFIDENCE_THRESHOLD {
468 tracing::warn!(
469 tool = tool.name(),
470 confidence = ctx.self_model_confidence,
471 "low self-model confidence — conservative dispatch mode"
472 );
473 if !tool.effects().writes.is_empty() {
475 return Err(CoreError::Governance(format!(
476 "tool '{}' requires write access but self-model confidence is {:.2} (minimum {:.2}) — conservative dispatch blocks writes; this is load-sensitive, retry when the host settles (deterministic runs can pin WM_HOMEOSTASIS_FROZEN=1)",
477 tool.name(),
478 ctx.self_model_confidence,
479 CONFIDENCE_THRESHOLD
480 )));
481 }
482 }
483
484 const DRIVE_CAUTION_THRESHOLD: f32 = 0.85;
486 if !tool.effects().writes.is_empty() && ctx.drive_caution > DRIVE_CAUTION_THRESHOLD {
487 tracing::warn!(
488 tool = tool.name(),
489 drive_caution = ctx.drive_caution,
490 "high drive caution — write operation flagged for review"
491 );
492 }
493
494 const DRIVE_ENERGY_THRESHOLD: f32 = 0.15;
496 if !tool.effects().writes.is_empty() && ctx.drive_energy < DRIVE_ENERGY_THRESHOLD {
497 tracing::warn!(
498 tool = tool.name(),
499 drive_energy = ctx.drive_energy,
500 "low drive energy — write operation may be resource-constrained"
501 );
502 }
503
504 match crate::capability_gate::evaluate(
512 tool.effects(),
513 &mut args,
514 self.capability_mode,
515 chrono::Utc::now().timestamp(),
516 ) {
517 Ok(GateOutcome::AdvisoryMissing { required }) => {
518 tracing::debug!(
519 tool = tool.name(),
520 required = %required.labels().join(", "),
521 mode = self.capability_mode.label(),
522 "capability gate: requirement unmet (advisory)"
523 );
524 }
525 Ok(_) => {}
526 Err(reason) => {
527 return Err(CoreError::Governance(format!("capability gate: {reason}")));
528 }
529 }
530
531 let verdict = self.dharma_gate.evaluate(tool.effects(), ctx);
535 match verdict {
536 ActionVerdict::Panic(reason) => {
537 tracing::error!(tool = tool.name(), reason = %reason, "Dharma PANIC");
538 return Err(CoreError::Governance(reason));
539 }
540 ActionVerdict::Intervene(reason) => {
541 tracing::warn!(tool = tool.name(), reason = %reason, "Dharma INTERVENE");
542 return Err(CoreError::Governance(reason));
543 }
544 ActionVerdict::Correct(reason) => {
545 tracing::info!(tool = tool.name(), reason = %reason, "Dharma CORRECT — proceeding with restrictions");
546 }
547 ActionVerdict::Advise(reason) => {
548 tracing::debug!(tool = tool.name(), reason = %reason, "Dharma ADVISE");
549 }
550 ActionVerdict::Observe => {}
551 }
552
553 let mut novelty_flag: Option<String> = None;
559 if let Some(ref rules) = self.resource_rules {
560 let effects = tool.effects();
561 let is_write = !effects.writes.is_empty();
562 let is_spawn = effects.spawns
563 || effects
564 .writes
565 .iter()
566 .chain(effects.reads.iter())
567 .any(|r| matches!(r, wm_core::Resource::Process));
568 let is_network = effects
569 .writes
570 .iter()
571 .chain(effects.reads.iter())
572 .any(|r| matches!(r, wm_core::Resource::Network));
573 let has_purpose = [args.get("purpose"), ctx.meta.get("purpose")]
574 .into_iter()
575 .flatten()
576 .filter_map(serde_json::Value::as_str)
577 .any(|p| !p.trim().is_empty());
578 let homeostasis = self.dharma_gate.homeostasis();
579 let verdict = rules.evaluate(
580 tool.name(),
581 hash_args(&args),
582 is_write,
583 is_spawn,
584 is_network,
585 has_purpose,
586 &homeostasis,
587 ctx.brain_wave,
588 );
589 match verdict {
590 ResourceVerdict::Allow => {}
591 ResourceVerdict::NotNovel { .. } => {
592 novelty_flag = Some(verdict.reason());
593 tracing::warn!(
594 tool = tool.name(),
595 reason = %verdict.reason(),
596 "resource rules: novelty flag on response"
597 );
598 }
599 ResourceVerdict::BudgetExceeded { .. }
600 | ResourceVerdict::RequiresHumanReview { .. }
601 | ResourceVerdict::NoPurpose { .. } => {
602 tracing::warn!(
603 tool = tool.name(),
604 reason = %verdict.reason(),
605 "resource rules: dispatch blocked"
606 );
607 return Err(CoreError::Governance(format!(
608 "resource rules: {}",
609 verdict.reason()
610 )));
611 }
612 }
613 }
614
615 let gate_disclosure: Option<serde_json::Value> = if let Some(ref gate) = self.write_gate {
621 let outcome = gate.enforce(tool.name(), &mut args)?;
622 if let Some(sc) = outcome.short_circuit {
623 return Ok(sc);
624 }
625 outcome.disclosure
626 } else {
627 None
628 };
629
630 if let Err(retry_after_ms) = self.rate_limiter.try_acquire(tool.name()) {
632 return Err(CoreError::RateLimited(format!(
633 "{}: retry after {}ms",
634 tool.name(),
635 retry_after_ms
636 )));
637 }
638
639 if self.circuit_breakers.is_open(tool.name()) {
641 return Err(CoreError::CircuitBreaker(tool.name().to_string()));
642 }
643
644 let confirm_gated = if tool.effects().destructive {
647 if !confirmed {
648 return Err(CoreError::Governance(format!(
649 "tool '{}' is destructive — pass `\"confirm\": true` in args to proceed",
650 tool.name()
651 )));
652 }
653 Some(true)
656 } else {
657 None
658 };
659
660 let mut firebreak_advisories: Vec<String> = Vec::new();
668 if let Some(ref firebreak) = self.firebreak {
669 match firebreak.enforce(tool.name(), tool.effects(), &args) {
670 FirebreakOutcome::Blocked(reason) => {
671 tracing::warn!(tool = tool.name(), reason = %reason, "firebreak VETO");
672 return Err(CoreError::Governance(reason));
673 }
674 FirebreakOutcome::Proceed { advisories } if !advisories.is_empty() => {
675 tracing::info!(tool = tool.name(), advisories = ?advisories, "firebreak advisories");
676 firebreak_advisories = advisories;
677 }
678 FirebreakOutcome::Proceed { .. } => {}
679 }
680 }
681
682 let has_runtime_galaxy = args
696 .get("galaxy")
697 .and_then(serde_json::Value::as_str)
698 .is_some_and(|g| !g.is_empty());
699 let mut checked_galaxies: Vec<wm_core::Galaxy> = Vec::new();
700
701 if !has_runtime_galaxy {
702 for resource in &tool.effects().reads {
703 if let wm_core::Resource::Galaxy(name) = resource {
704 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
705 if !ctx.can_access_galaxy(galaxy) {
706 return Err(CoreError::Governance(format!(
707 "compartment '{}' cannot read galaxy '{}' (tool '{}')",
708 ctx.compartment.as_deref().unwrap_or("none"),
709 name,
710 tool.name()
711 )));
712 }
713 checked_galaxies.push(galaxy);
714 }
715 }
716 }
717 for resource in &tool.effects().writes {
718 if let wm_core::Resource::Galaxy(name) = resource {
719 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
720 if !ctx.can_write_galaxy(galaxy) {
721 return Err(CoreError::Governance(format!(
722 "compartment '{}' cannot write to galaxy '{}' (tool '{}')",
723 ctx.compartment.as_deref().unwrap_or("none"),
724 name,
725 tool.name()
726 )));
727 }
728 checked_galaxies.push(galaxy);
729 }
730 }
731 }
732 }
733
734 if let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str) {
736 if !galaxy_str.is_empty() {
737 if let Some(runtime_galaxy) = wm_core::Galaxy::from_db_name(galaxy_str) {
738 if !checked_galaxies.contains(&runtime_galaxy) {
739 let has_writes = !tool.effects().writes.is_empty();
741 if has_writes {
742 if !ctx.can_write_galaxy(runtime_galaxy) {
743 return Err(CoreError::Governance(format!(
744 "compartment '{}' cannot write to galaxy '{}' (tool '{}' runtime arg)",
745 ctx.compartment.as_deref().unwrap_or("none"),
746 galaxy_str,
747 tool.name()
748 )));
749 }
750 } else if !ctx.can_access_galaxy(runtime_galaxy) {
751 return Err(CoreError::Governance(format!(
752 "compartment '{}' cannot read galaxy '{}' (tool '{}' runtime arg)",
753 ctx.compartment.as_deref().unwrap_or("none"),
754 galaxy_str,
755 tool.name()
756 )));
757 }
758 }
759 }
760 }
761 }
762
763 if !tool.effects().writes.is_empty()
769 && let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str)
770 && galaxy_str == "citta"
771 && !tool
772 .effects()
773 .reads
774 .iter()
775 .any(|r| matches!(r, wm_core::Resource::Galaxy(g) if g == "citta"))
776 {
777 return Err(CoreError::Governance(
778 "VIOLATION_SATYA: writing to citta (runtime galaxy) without reading — memory fabrication is forbidden"
779 .to_string(),
780 ));
781 }
782
783 let args_memory_id = first_str(&args, &["id", "memory_id", "memory"]);
791 let args_content_hash = first_str(&args, &["content_hash", "hash", "sha256"]);
792 let args_digest = wm_governance::args_digest(tool.name(), &args);
797 if let Some(ref flight) = self.flight_recorder {
803 if let Err(e) = flight.record(tool.name(), &args) {
804 tracing::warn!(error = %e, "Flight recorder capture failed (replay will refuse)");
805 }
806 }
807 let write_audit_baseline = self
808 .write_audit
809 .as_ref()
810 .map_or(0, |j| j.dispatch_baseline());
811 let mut spawn_disclosure: Option<serde_json::Value> = None;
817 if let Some(sb) = self.subprocess_sandbox.as_deref() {
818 if crate::subprocess_sandbox::SubprocessSandbox::declared(tool.effects()) {
819 let policy = sb.policy_for(tool.effects());
820 if policy.is_active() {
821 let mut disclosure = serde_json::json!({
822 "net": policy.allow_net(),
823 "envelope": wm_core::sandbox::ENVELOPE_SCHEMA,
824 });
825 if let Some(runner) = policy.runner()
826 && let Some(obj) = disclosure.as_object_mut()
827 {
828 obj.insert(
829 "runner".to_string(),
830 serde_json::Value::String(runner.display().to_string()),
831 );
832 }
833 sb.note_confined();
834 spawn_disclosure = Some(disclosure);
835 } else {
836 sb.note_degraded(tool.name());
837 }
838 ctx.spawn = policy;
839 } else if tool.effects().spawns {
840 sb.note_unconfined_spawn(tool.name());
841 }
842 }
843 let result = if crate::sandbox_exec::ScopedSandboxExecutor::handles(tool)
847 && let Some(executor) = self.sandbox_exec.as_deref()
848 {
849 executor.run(tool, ctx, args)
850 } else if let Some(timeout) = self.dispatch_timeout {
851 if let Ok(res) = tokio::time::timeout(timeout, tool.call(ctx, args)).await {
852 res
853 } else {
854 tracing::error!(
855 tool = tool.name(),
856 timeout_ms = timeout.as_millis(),
857 "tool dispatch timed out"
858 );
859 self.circuit_breakers.record_failure(tool.name());
860 return Err(CoreError::Tool(format!(
861 "tool '{}' timed out after {}ms",
862 tool.name(),
863 timeout.as_millis()
864 )));
865 }
866 } else {
867 tool.call(ctx, args).await
868 };
869 let elapsed = start.elapsed();
870
871 if let Some(ref scanner) = self.secret_scan {
876 if let Ok(ref output) = result {
877 scanner.scan(tool.name(), output);
878 }
879 }
880
881 let result = match (result, novelty_flag) {
883 (Ok(mut output), Some(flag)) => {
884 if let serde_json::Value::Object(ref mut map) = output {
885 match map.get_mut("resource_flags") {
886 Some(serde_json::Value::Array(arr)) => {
887 arr.push(serde_json::Value::String(flag));
888 }
889 Some(_) => {}
890 None => {
891 map.insert(
892 "resource_flags".to_string(),
893 serde_json::Value::Array(vec![serde_json::Value::String(flag)]),
894 );
895 }
896 }
897 }
898 Ok(output)
899 }
900 (result, _) => result,
901 };
902
903 let result = match (result, gate_disclosure) {
906 (Ok(mut output), Some(disclosure)) => {
907 if let serde_json::Value::Object(ref mut map) = output {
908 map.insert("write_gate".to_string(), disclosure);
909 }
910 Ok(output)
911 }
912 (result, _) => result,
913 };
914
915 let result = match (result, firebreak_advisories) {
919 (Ok(mut output), advisories) if !advisories.is_empty() => {
920 if let serde_json::Value::Object(ref mut map) = output {
921 map.insert(
922 "firebreak".to_string(),
923 serde_json::json!({ "advisories": advisories }),
924 );
925 }
926 Ok(output)
927 }
928 (result, _) => result,
929 };
930
931 let result = match (result, spawn_disclosure) {
934 (Ok(mut output), Some(disclosure)) => {
935 if let serde_json::Value::Object(ref mut map) = output {
936 map.insert("sandbox".to_string(), disclosure);
937 }
938 Ok(output)
939 }
940 (result, _) => result,
941 };
942
943 if let Ok(output) = &result {
945 tool.stats().record_success(elapsed, elapsed);
946 self.circuit_breakers.record_success(tool.name());
947
948 if let Some(ref ledger) = self.karma_ledger {
949 let declared_writes = !tool.effects().writes.is_empty();
950 let actual_writes = output
951 .get("writes")
952 .and_then(|w| w.as_array())
953 .map_or(0, |a| a.len() as u32);
954 if let Err(e) = ledger.record(tool.name(), declared_writes, actual_writes, true) {
955 tracing::warn!(error = %e, "Karma ledger record failed");
956 }
957 ctx.karma_debt = ledger.total_debt();
958 }
959
960 if let Some(ref journal) = self.write_audit {
961 let declared_writes = !tool.effects().writes.is_empty();
962 record_write_audit(
963 journal,
964 write_audit_baseline,
965 tool.name(),
966 wm_governance::ActorIdentity::from_context(ctx),
967 declared_writes,
968 args_memory_id.as_deref(),
969 args_content_hash.as_deref(),
970 Some(args_digest),
971 output,
972 true,
973 confirm_gated,
974 );
975 }
976 } else {
977 tool.stats().record_failure(elapsed);
978 self.circuit_breakers.record_failure(tool.name());
979
980 if let Some(ref ledger) = self.karma_ledger {
981 let declared_writes = !tool.effects().writes.is_empty();
982 if let Err(ke) = ledger.record(tool.name(), declared_writes, 0, false) {
983 tracing::warn!(error = %ke, "Karma ledger record failed");
984 }
985 ctx.karma_debt = ledger.total_debt();
986 }
987
988 if let Some(ref journal) = self.write_audit {
989 let declared_writes = !tool.effects().writes.is_empty();
990 record_write_audit(
991 journal,
992 write_audit_baseline,
993 tool.name(),
994 wm_governance::ActorIdentity::from_context(ctx),
995 declared_writes,
996 args_memory_id.as_deref(),
997 args_content_hash.as_deref(),
998 Some(args_digest),
999 &serde_json::Value::Null,
1000 false,
1001 confirm_gated,
1002 );
1003 }
1004 }
1005
1006 if let Some(ref registry) = self.gana_registry {
1008 if let Ok(mut reg) = registry.lock() {
1009 let gana = tool.gana();
1010 reg.record_usage(gana, result.is_ok());
1011 if let Some(prev) = ctx.last_gana {
1013 reg.record_co_usage(prev, gana);
1014 }
1015 ctx.last_gana = Some(gana);
1016 }
1017 }
1018
1019 result
1020 }
1021
1022 pub async fn dispatch_by_name(
1027 &self,
1028 registry: &crate::ToolRegistry,
1029 name: &str,
1030 ctx: &mut Context,
1031 args: Args,
1032 ) -> Result<Output> {
1033 let tool = registry
1034 .get(name)
1035 .ok_or_else(|| CoreError::NotFound(format!("tool '{name}' not registered")))?;
1036 self.dispatch(tool.as_ref(), ctx, args).await
1037 }
1038
1039 #[must_use]
1041 pub fn rate_limiter(&self) -> &RateLimiter {
1042 &self.rate_limiter
1043 }
1044
1045 #[must_use]
1047 pub fn circuit_breakers(&self) -> &CircuitBreakerRegistry {
1048 &self.circuit_breakers
1049 }
1050
1051 #[must_use]
1053 pub fn dharma_gate(&self) -> &DharmaGate {
1054 &self.dharma_gate
1055 }
1056
1057 #[must_use]
1059 pub fn karma_ledger(&self) -> Option<&KarmaLedger> {
1060 self.karma_ledger.as_deref()
1061 }
1062}
1063
1064impl Default for DispatchPipeline {
1065 fn default() -> Self {
1066 Self::with_defaults()
1067 }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use wm_core::{BrainWave, EffectRow, Gana, Sandbox, ToolStats};
1074 use wm_governance::{ResourceRulesConfig, WriteAuditJournal};
1075
1076 struct TestTool {
1077 name: String,
1078 effects: EffectRow,
1079 stats: ToolStats,
1080 should_fail: bool,
1081 output: Option<Output>,
1082 store: Option<Arc<wm_memory::MemoryStore>>,
1085 }
1086
1087 impl TestTool {
1088 fn new(name: &str, effects: EffectRow) -> Self {
1089 Self {
1090 name: name.to_string(),
1091 effects,
1092 stats: ToolStats::default(),
1093 should_fail: false,
1094 output: None,
1095 store: None,
1096 }
1097 }
1098
1099 fn with_output(mut self, output: Output) -> Self {
1100 self.output = Some(output);
1101 self
1102 }
1103
1104 fn with_store(mut self, store: Arc<wm_memory::MemoryStore>) -> Self {
1105 self.store = Some(store);
1106 self
1107 }
1108
1109 fn failing(name: &str) -> Self {
1110 Self {
1111 name: name.to_string(),
1112 effects: EffectRow::pure(),
1113 stats: ToolStats::default(),
1114 should_fail: true,
1115 output: None,
1116 store: None,
1117 }
1118 }
1119 }
1120
1121 #[async_trait]
1122 impl Tool for TestTool {
1123 fn name(&self) -> &str {
1124 &self.name
1125 }
1126 fn gana(&self) -> Gana {
1127 Gana::Heart
1128 }
1129 fn effects(&self) -> &EffectRow {
1130 &self.effects
1131 }
1132 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1133 if let Some(store) = &self.store {
1134 let mem = wm_memory::Memory::new(
1135 wm_core::Galaxy::Codex,
1136 format!("misdeclared write from {}", self.name),
1137 );
1138 store.put(wm_core::Galaxy::Codex, &mem).ok();
1139 }
1140 if self.should_fail {
1141 Err(CoreError::Tool(self.name.clone()))
1142 } else {
1143 Ok(self
1144 .output
1145 .clone()
1146 .unwrap_or_else(|| serde_json::json!("ok")))
1147 }
1148 }
1149 fn stats(&self) -> &ToolStats {
1150 &self.stats
1151 }
1152 }
1153
1154 #[tokio::test]
1155 async fn pipeline_dispatch_success() {
1156 let pipeline = DispatchPipeline::with_defaults();
1157 let mut ctx = Context::new(BrainWave::Gamma);
1158 let tool = TestTool::new("test_tool", EffectRow::pure());
1159
1160 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1161 assert!(result.is_ok());
1162 }
1163
1164 struct HangingTool {
1165 effects: EffectRow,
1166 stats: ToolStats,
1167 }
1168
1169 impl HangingTool {
1170 fn new() -> Self {
1171 Self {
1172 effects: EffectRow::pure(),
1173 stats: ToolStats::default(),
1174 }
1175 }
1176 }
1177
1178 #[async_trait]
1179 impl Tool for HangingTool {
1180 fn name(&self) -> &str {
1181 "hanging_tool"
1182 }
1183 fn gana(&self) -> Gana {
1184 Gana::Heart
1185 }
1186 fn effects(&self) -> &EffectRow {
1187 &self.effects
1188 }
1189 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1190 tokio::time::sleep(Duration::from_secs(30)).await;
1191 Ok(serde_json::json!("never reached"))
1192 }
1193 fn stats(&self) -> &ToolStats {
1194 &self.stats
1195 }
1196 }
1197
1198 #[tokio::test]
1199 async fn pipeline_dispatch_timeout_bounds_hung_tool() {
1200 let pipeline = DispatchPipeline::with_defaults()
1201 .with_dispatch_timeout(Some(Duration::from_millis(50)));
1202 let mut ctx = Context::new(BrainWave::Gamma);
1203 let tool = HangingTool::new();
1204
1205 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1206 assert!(result.is_err());
1207 let msg = result.err().unwrap().to_string();
1208 assert!(
1209 msg.contains("timed out"),
1210 "expected timeout error, got: {msg}"
1211 );
1212 }
1213
1214 #[tokio::test]
1215 async fn pipeline_dispatch_with_timeout_allows_fast_tool() {
1216 let pipeline = DispatchPipeline::with_defaults()
1217 .with_dispatch_timeout(Some(Duration::from_millis(500)));
1218 let mut ctx = Context::new(BrainWave::Gamma);
1219 let tool = TestTool::new("fast_tool", EffectRow::pure());
1220
1221 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1222 assert!(result.is_ok());
1223 }
1224
1225 #[tokio::test]
1226 async fn pipeline_dispatch_failure_records_stats() {
1227 let pipeline = DispatchPipeline::with_defaults();
1228 let mut ctx = Context::new(BrainWave::Gamma);
1229 let tool = TestTool::failing("failing_tool");
1230
1231 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1232 assert!(result.is_err());
1233 assert_eq!(
1234 tool.stats()
1235 .call_count
1236 .load(std::sync::atomic::Ordering::Relaxed),
1237 1
1238 );
1239 }
1240
1241 #[tokio::test]
1242 async fn pipeline_blocks_incompatible_brain_wave() {
1243 let pipeline = DispatchPipeline::with_defaults();
1244 let mut ctx = Context::new(BrainWave::Delta);
1245 let tool = TestTool::new("test_tool", EffectRow::pure());
1246
1247 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1248 assert!(result.is_err());
1249 match result {
1250 Err(CoreError::Governance(_)) => {}
1251 other => panic!("Expected Governance error, got {other:?}"),
1252 }
1253 }
1254
1255 #[tokio::test]
1256 async fn pipeline_dharma_blocks_destructive_in_strict_mode() {
1257 let pipeline = DispatchPipeline::with_defaults();
1258 let mut ctx = Context::new(BrainWave::Theta);
1259 let tool = TestTool::new(
1260 "destructive_tool",
1261 EffectRow {
1262 writes: vec![wm_core::Resource::Filesystem],
1263 ..Default::default()
1264 },
1265 );
1266
1267 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1268 assert!(result.is_err());
1269 match result {
1270 Err(CoreError::Governance(_)) => {}
1271 other => panic!("Expected Governance error, got {other:?}"),
1272 }
1273 }
1274
1275 #[tokio::test]
1276 async fn pipeline_dharma_confirm_passes_brain_wave_strict_for_destructive() {
1277 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(Arc::new(
1281 ResourceRules::new(ResourceRulesConfig {
1282 require_human_review: false,
1283 ..Default::default()
1284 }),
1285 ));
1286 let mut ctx = Context::new(BrainWave::Theta);
1287 let tool = TestTool::new(
1288 "destructive_tool",
1289 EffectRow {
1290 writes: vec![wm_core::Resource::Filesystem],
1291 ..Default::default()
1292 },
1293 );
1294 let result = pipeline
1295 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1296 .await;
1297 assert!(
1298 result.is_ok(),
1299 "confirmed destructive dispatch must pass brain-wave strict: {result:?}"
1300 );
1301 }
1302
1303 #[tokio::test]
1304 async fn pipeline_capability_gate_strict_blocks_uncredentialed() {
1305 let pipeline =
1306 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1307 let mut ctx = Context::new(BrainWave::Gamma);
1308 let tool = TestTool::new(
1309 "capability_tool",
1310 EffectRow {
1311 invokes: vec![wm_core::Capability::MemoryWrite],
1312 ..Default::default()
1313 },
1314 );
1315
1316 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1317 match result {
1318 Err(CoreError::Governance(msg)) => {
1319 assert!(msg.contains("capability gate"), "{msg}");
1320 assert!(msg.contains("memory:write"), "{msg}");
1321 }
1322 other => panic!("Expected capability refusal, got {other:?}"),
1323 }
1324 }
1325
1326 #[tokio::test]
1327 async fn pipeline_capability_gate_strict_allows_valid_token() {
1328 let pipeline =
1329 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1330 let mut ctx = Context::new(BrainWave::Gamma);
1331 let tool = TestTool::new(
1332 "capability_tool_ok",
1333 EffectRow {
1334 invokes: vec![wm_core::Capability::MemoryWrite],
1335 ..Default::default()
1336 },
1337 );
1338
1339 let mut issuer = wm_governance::engagement_tokens::EngagementIssuer::with_keypair(
1340 wm_governance::network_profile::AgentKeypair::from_seed([7u8; 32]),
1341 );
1342 let issuer_key = issuer.signer_public_key_hex();
1343 let token = issuer.issue(
1344 "tester",
1345 wm_governance::engagement_tokens::EngagementScope::Poc,
1346 "rules-hash",
1347 Some(3600),
1348 );
1349 let args = serde_json::json!({
1350 "_engagement": { "token": token, "issuer_public_key": issuer_key }
1351 });
1352
1353 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
1354 assert!(result.is_ok(), "valid Poc token should pass: {result:?}");
1355 }
1356
1357 #[tokio::test]
1358 async fn pipeline_capability_gate_advisory_allows_uncredentialed() {
1359 let pipeline =
1360 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Advisory);
1361 let mut ctx = Context::new(BrainWave::Gamma);
1362 let tool = TestTool::new(
1363 "capability_tool_advisory",
1364 EffectRow {
1365 invokes: vec![wm_core::Capability::MemoryWrite],
1366 ..Default::default()
1367 },
1368 );
1369
1370 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1371 assert!(result.is_ok(), "advisory mode must not block: {result:?}");
1372 }
1373
1374 #[tokio::test]
1375 async fn pipeline_rate_limit_blocks_excess() {
1376 let rate_limiter = Arc::new(RateLimiter::new(1000, 2, 0));
1377 let pipeline = DispatchPipeline::new(
1378 rate_limiter,
1379 Arc::new(CircuitBreakerRegistry::default()),
1380 Arc::new(DharmaGate::default()),
1381 None,
1382 );
1383
1384 let mut ctx = Context::new(BrainWave::Gamma);
1385 let tool = TestTool::new("limited_tool", EffectRow::pure());
1386
1387 assert!(
1388 pipeline
1389 .dispatch(&tool, &mut ctx, Args::default())
1390 .await
1391 .is_ok()
1392 );
1393 assert!(
1394 pipeline
1395 .dispatch(&tool, &mut ctx, Args::default())
1396 .await
1397 .is_ok()
1398 );
1399 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1400 assert!(result.is_err());
1401 match result {
1402 Err(CoreError::RateLimited(_)) => {}
1403 other => panic!("Expected RateLimited error, got {other:?}"),
1404 }
1405 }
1406
1407 #[tokio::test]
1408 async fn pipeline_circuit_breaker_opens_on_repeated_failures() {
1409 let breakers = Arc::new(CircuitBreakerRegistry::new(
1410 crate::circuit_breaker::BreakerConfig {
1411 failure_threshold: 3,
1412 window: std::time::Duration::from_secs(10),
1413 cooldown: std::time::Duration::from_secs(30),
1414 },
1415 ));
1416 let pipeline = DispatchPipeline::new(
1417 Arc::new(RateLimiter::new(10000, 100, 100)),
1418 breakers.clone(),
1419 Arc::new(DharmaGate::default()),
1420 None,
1421 );
1422
1423 let mut ctx = Context::new(BrainWave::Gamma);
1424 let tool = TestTool::failing("flaky_tool");
1425
1426 for _ in 0..3 {
1427 let _ = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1428 }
1429
1430 assert_eq!(
1431 breakers.state("flaky_tool"),
1432 crate::circuit_breaker::BreakerState::Open
1433 );
1434
1435 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1436 assert!(result.is_err());
1437 match result {
1438 Err(CoreError::CircuitBreaker(_)) => {}
1439 other => panic!("Expected CircuitBreaker error, got {other:?}"),
1440 }
1441 }
1442
1443 #[tokio::test]
1444 async fn pipeline_karma_ledger_records() {
1445 let tmp = tempfile::tempdir().unwrap();
1446 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1447 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1448
1449 let pipeline = DispatchPipeline::new(
1450 Arc::new(RateLimiter::default()),
1451 Arc::new(CircuitBreakerRegistry::default()),
1452 Arc::new(DharmaGate::default()),
1453 Some(ledger.clone()),
1454 );
1455
1456 let mut ctx = Context::new(BrainWave::Gamma);
1457 let tool = TestTool::new("karma_test_tool", EffectRow::pure());
1458
1459 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1460 assert!(result.is_ok());
1461 assert_eq!(ledger.next_id(), 1);
1462 assert_eq!(ctx.karma_debt, 0.0);
1463 }
1464
1465 #[tokio::test]
1466 async fn pipeline_karma_debt_updates_context() {
1467 let tmp = tempfile::tempdir().unwrap();
1468 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1469 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1470
1471 let pipeline = DispatchPipeline::new(
1472 Arc::new(RateLimiter::default()),
1473 Arc::new(CircuitBreakerRegistry::default()),
1474 Arc::new(DharmaGate::default()),
1475 Some(ledger),
1476 );
1477
1478 let mut ctx = Context::new(BrainWave::Gamma);
1479 let tool = TestTool::new(
1480 "wasteful_tool",
1481 EffectRow {
1482 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1483 ..Default::default()
1484 },
1485 );
1486
1487 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1488 assert!(result.is_ok());
1489 assert!(
1490 (ctx.karma_debt - 0.2).abs() < 0.001,
1491 "Context karma_debt should be 0.2, got {}",
1492 ctx.karma_debt
1493 );
1494 }
1495
1496 #[tokio::test]
1497 async fn pipeline_karma_batched_e2e() {
1498 let tmp = tempfile::tempdir().unwrap();
1501 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1502 let ledger = Arc::new(KarmaLedger::with_flush_threshold(store.clone(), 100).unwrap());
1503
1504 let pipeline = DispatchPipeline::new(
1505 Arc::new(RateLimiter::default()),
1506 Arc::new(CircuitBreakerRegistry::default()),
1507 Arc::new(DharmaGate::default()),
1508 Some(ledger.clone()),
1509 );
1510
1511 let mut ctx = Context::new(BrainWave::Gamma);
1512
1513 let honest_tool = TestTool::new("honest_tool", EffectRow::pure());
1515 let wasteful_tool = TestTool::new(
1516 "wasteful_tool",
1517 EffectRow {
1518 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1519 ..Default::default()
1520 },
1521 );
1522
1523 for _ in 0..10 {
1524 let result = pipeline
1525 .dispatch(&honest_tool, &mut ctx, Args::default())
1526 .await;
1527 assert!(result.is_ok());
1528 }
1529 for _ in 0..10 {
1530 let result = pipeline
1531 .dispatch(&wasteful_tool, &mut ctx, Args::default())
1532 .await;
1533 assert!(result.is_ok());
1534 }
1535
1536 assert_eq!(ledger.next_id(), 20);
1538 assert_eq!(
1539 ledger.pending_count(),
1540 20,
1541 "All 20 entries should be pending before flush"
1542 );
1543
1544 let debt = ledger.total_debt();
1546 assert!(
1547 (debt - 2.0).abs() < 0.001,
1548 "Total debt should be 2.0 (10 x 0.2), got {debt}"
1549 );
1550
1551 ledger.flush().unwrap();
1553 assert_eq!(ledger.pending_count(), 0);
1554
1555 let result = ledger.verify_integrity().unwrap();
1557 assert!(
1558 result.valid,
1559 "Chain should be valid after batched flush: {:?}",
1560 result.violation
1561 );
1562 assert_eq!(result.entries_verified, 20);
1563
1564 let ledger2 = KarmaLedger::new(store).unwrap();
1566 assert_eq!(
1567 ledger2.next_id(),
1568 20,
1569 "Next ID should persist across instances"
1570 );
1571 let entries = ledger2.scan_entries().unwrap();
1572 assert_eq!(
1573 entries.len(),
1574 20,
1575 "All 20 entries should be persisted in LMDB"
1576 );
1577
1578 let debt2 = ledger2.total_debt();
1580 assert!(
1581 (debt2 - 2.0).abs() < 0.001,
1582 "Total debt should persist as 2.0, got {debt2}"
1583 );
1584
1585 let result2 = ledger2.verify_integrity().unwrap();
1587 assert!(result2.valid, "Chain should be valid on reloaded ledger");
1588 assert_eq!(result2.entries_verified, 20);
1589 }
1590
1591 #[tokio::test]
1592 async fn pipeline_coherence_gate_blocks_writes() {
1593 let pipeline = DispatchPipeline::with_defaults();
1594 let mut ctx = Context::new(BrainWave::Gamma);
1595 ctx.citta_coherence = 0.1; let tool = TestTool::new(
1597 "write_tool",
1598 EffectRow {
1599 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1600 ..Default::default()
1601 },
1602 );
1603
1604 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1605 assert!(result.is_err());
1606 match result {
1607 Err(CoreError::Governance(msg)) => {
1608 assert!(msg.contains("coherence"));
1609 }
1610 other => panic!("Expected Governance error, got {other:?}"),
1611 }
1612 }
1613
1614 #[tokio::test]
1615 async fn pipeline_coherence_gate_allows_reads() {
1616 let pipeline = DispatchPipeline::with_defaults();
1617 let mut ctx = Context::new(BrainWave::Gamma);
1618 ctx.citta_coherence = 0.1; let tool = TestTool::new("read_tool", EffectRow::pure());
1620
1621 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1622 assert!(result.is_ok());
1623 }
1624
1625 #[tokio::test]
1626 async fn pipeline_coherence_gate_allows_writes_when_coherent() {
1627 let pipeline = DispatchPipeline::with_defaults();
1628 let mut ctx = Context::new(BrainWave::Gamma);
1629 ctx.citta_coherence = 0.5; let tool = TestTool::new(
1631 "write_tool",
1632 EffectRow {
1633 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1634 ..Default::default()
1635 },
1636 );
1637
1638 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1639 assert!(result.is_ok());
1640 }
1641
1642 #[tokio::test]
1643 async fn pipeline_low_confidence_blocks_writes() {
1644 let pipeline = DispatchPipeline::with_defaults();
1645 let mut ctx = Context::new(BrainWave::Gamma);
1646 ctx.self_model_confidence = 0.3; let tool = TestTool::new(
1648 "write_tool",
1649 EffectRow {
1650 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1651 ..Default::default()
1652 },
1653 );
1654
1655 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1656 assert!(result.is_err());
1657 match result {
1658 Err(CoreError::Governance(msg)) => {
1659 assert!(msg.contains("confidence"));
1660 assert!(msg.contains("conservative"));
1661 }
1662 other => panic!("Expected Governance error, got {other:?}"),
1663 }
1664 }
1665
1666 #[tokio::test]
1667 async fn pipeline_low_confidence_allows_reads() {
1668 let pipeline = DispatchPipeline::with_defaults();
1669 let mut ctx = Context::new(BrainWave::Gamma);
1670 ctx.self_model_confidence = 0.3; let tool = TestTool::new("read_tool", EffectRow::pure());
1672
1673 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1674 assert!(result.is_ok());
1675 }
1676
1677 #[tokio::test]
1678 async fn pipeline_high_confidence_allows_writes() {
1679 let pipeline = DispatchPipeline::with_defaults();
1680 let mut ctx = Context::new(BrainWave::Gamma);
1681 ctx.self_model_confidence = 0.8; let tool = TestTool::new(
1683 "write_tool",
1684 EffectRow {
1685 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1686 ..Default::default()
1687 },
1688 );
1689
1690 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1691 assert!(result.is_ok());
1692 }
1693
1694 #[tokio::test]
1695 async fn pipeline_high_caution_warns_on_writes() {
1696 let pipeline = DispatchPipeline::with_defaults();
1697 let mut ctx = Context::new(BrainWave::Gamma);
1698 ctx.drive_caution = 0.9; let tool = TestTool::new(
1700 "write_tool",
1701 EffectRow {
1702 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1703 ..Default::default()
1704 },
1705 );
1706
1707 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1709 assert!(result.is_ok());
1710 }
1711
1712 #[tokio::test]
1713 async fn pipeline_low_energy_warns_on_writes() {
1714 let pipeline = DispatchPipeline::with_defaults();
1715 let mut ctx = Context::new(BrainWave::Gamma);
1716 ctx.drive_energy = 0.1; let tool = TestTool::new(
1718 "write_tool",
1719 EffectRow {
1720 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1721 ..Default::default()
1722 },
1723 );
1724
1725 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1727 assert!(result.is_ok());
1728 }
1729
1730 #[tokio::test]
1731 async fn pipeline_drive_gates_dont_affect_reads() {
1732 let pipeline = DispatchPipeline::with_defaults();
1733 let mut ctx = Context::new(BrainWave::Gamma);
1734 ctx.drive_caution = 0.95;
1735 ctx.drive_energy = 0.05;
1736 let tool = TestTool::new("read_tool", EffectRow::pure());
1737
1738 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1739 assert!(result.is_ok());
1740 }
1741
1742 #[tokio::test]
1743 async fn pipeline_destructive_blocked_without_confirm() {
1744 let pipeline = DispatchPipeline::with_defaults();
1745 let mut ctx = Context::new(BrainWave::Gamma);
1746 let tool = TestTool::new(
1747 "destructive_tool",
1748 EffectRow {
1749 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1750 destructive: true,
1751 ..Default::default()
1752 },
1753 );
1754
1755 let result = pipeline
1756 .dispatch(&tool, &mut ctx, serde_json::json!({}))
1757 .await;
1758 assert!(result.is_err());
1759 match result {
1760 Err(CoreError::Governance(msg)) => {
1761 assert!(msg.contains("destructive"));
1762 assert!(msg.contains("confirm"));
1763 }
1764 other => panic!("Expected Governance error, got {other:?}"),
1765 }
1766 }
1767
1768 #[tokio::test]
1769 async fn pipeline_destructive_allowed_with_confirm() {
1770 let pipeline = DispatchPipeline::with_defaults();
1771 let mut ctx = Context::new(BrainWave::Gamma);
1772 let tool = TestTool::new(
1773 "destructive_tool",
1774 EffectRow {
1775 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1776 destructive: true,
1777 ..Default::default()
1778 },
1779 );
1780
1781 let result = pipeline
1782 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1783 .await;
1784 assert!(result.is_ok());
1785 }
1786
1787 #[tokio::test]
1788 async fn pipeline_destructive_blocked_with_false_confirm() {
1789 let pipeline = DispatchPipeline::with_defaults();
1790 let mut ctx = Context::new(BrainWave::Gamma);
1791 let tool = TestTool::new(
1792 "destructive_tool",
1793 EffectRow {
1794 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1795 destructive: true,
1796 ..Default::default()
1797 },
1798 );
1799
1800 let result = pipeline
1801 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": false}))
1802 .await;
1803 assert!(result.is_err());
1804 }
1805
1806 #[tokio::test]
1807 async fn pipeline_compartment_no_restriction_allows_all() {
1808 let pipeline = DispatchPipeline::with_defaults();
1809 let mut ctx = Context::new(BrainWave::Gamma);
1810 let tool = TestTool::new(
1812 "write_tool",
1813 EffectRow {
1814 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1815 ..Default::default()
1816 },
1817 );
1818
1819 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1820 assert!(result.is_ok());
1821 }
1822
1823 #[tokio::test]
1824 async fn pipeline_compartment_sandbox_blocks_write_to_codex() {
1825 let pipeline = DispatchPipeline::with_defaults();
1826 let mut ctx = Context::new(BrainWave::Gamma);
1827 ctx.compartment = Some("sandbox".into());
1828 let tool = TestTool::new(
1829 "write_tool",
1830 EffectRow {
1831 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1832 ..Default::default()
1833 },
1834 );
1835
1836 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1837 assert!(result.is_err());
1838 match result {
1839 Err(CoreError::Governance(msg)) => {
1840 assert!(msg.contains("sandbox"));
1841 assert!(msg.contains("codex"));
1842 }
1843 other => panic!("Expected Governance error, got {other:?}"),
1844 }
1845 }
1846
1847 #[tokio::test]
1848 async fn pipeline_asserted_user_id_confers_no_authority() {
1849 let pipeline = DispatchPipeline::with_defaults();
1854 let mut ctx = Context::new(BrainWave::Gamma);
1855 ctx.compartment = Some("sandbox".into());
1856 ctx.user_id = Some("ceo".into());
1857 let tool = TestTool::new(
1858 "write_tool",
1859 EffectRow {
1860 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1861 ..Default::default()
1862 },
1863 );
1864
1865 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1866 assert!(result.is_err());
1867 match result {
1868 Err(CoreError::Governance(msg)) => {
1869 assert!(msg.contains("sandbox"));
1870 assert!(msg.contains("codex"));
1871 }
1872 other => panic!("Expected Governance error, got {other:?}"),
1873 }
1874 }
1875
1876 #[tokio::test]
1877 async fn pipeline_routes_store_scoped_tools_through_executor() {
1878 use crate::sandbox_exec::ScopedSandboxExecutor;
1881 use std::sync::atomic::{AtomicU64, Ordering};
1882 let calls = Arc::new(AtomicU64::new(0));
1883 let counter = Arc::clone(&calls);
1884 let executor = Arc::new(ScopedSandboxExecutor::new(move || {
1885 counter.fetch_add(1, Ordering::SeqCst);
1886 Ok(())
1887 }));
1888 let pipeline =
1889 DispatchPipeline::with_defaults().with_sandbox_executor(Some(Arc::clone(&executor)));
1890 let mut ctx = Context::new(BrainWave::Gamma);
1891
1892 let scoped = TestTool::new(
1893 "scoped_tool",
1894 EffectRow {
1895 sandbox: Sandbox::StoreScoped,
1896 ..Default::default()
1897 },
1898 );
1899 assert!(
1900 pipeline
1901 .dispatch(&scoped, &mut ctx, Args::default())
1902 .await
1903 .is_ok()
1904 );
1905 assert_eq!(calls.load(Ordering::SeqCst), 1, "scoped tool must confine");
1906
1907 let plain = TestTool::new("plain_tool", EffectRow::pure());
1908 assert!(
1909 pipeline
1910 .dispatch(&plain, &mut ctx, Args::default())
1911 .await
1912 .is_ok()
1913 );
1914 assert_eq!(
1915 calls.load(Ordering::SeqCst),
1916 1,
1917 "plain tools must not ride the sandbox path"
1918 );
1919 assert_eq!(executor.stats(), (1, 0, 0));
1920
1921 let bare = DispatchPipeline::with_defaults();
1923 let scoped2 = TestTool::new(
1924 "scoped_tool",
1925 EffectRow {
1926 sandbox: Sandbox::StoreScoped,
1927 ..Default::default()
1928 },
1929 );
1930 assert!(
1931 bare.dispatch(&scoped2, &mut ctx, Args::default())
1932 .await
1933 .is_ok()
1934 );
1935 }
1936
1937 #[tokio::test]
1938 async fn pipeline_injects_subprocess_policy_and_discloses() {
1939 use crate::subprocess_sandbox::SubprocessSandbox;
1943 use std::path::PathBuf;
1944 use wm_core::sandbox::RunnerSource;
1945 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
1946 wm_core::sandbox::RunnerInfo {
1947 path: PathBuf::from("/opt/mandala-sandbox"),
1948 source: RunnerSource::Env,
1949 },
1950 )));
1951 let pipeline =
1952 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
1953 let mut ctx = Context::new(BrainWave::Gamma);
1954 let tool = TestTool::new(
1955 "spawn_tool",
1956 EffectRow {
1957 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
1958 spawns: true,
1959 sandbox: Sandbox::Subprocess,
1960 ..Default::default()
1961 },
1962 )
1963 .with_output(serde_json::json!({"ok": true}));
1964
1965 let out = pipeline
1966 .dispatch(&tool, &mut ctx, Args::default())
1967 .await
1968 .expect("declared spawn tool dispatches");
1969 assert!(ctx.spawn.is_active(), "policy must ride the context");
1970 assert!(ctx.spawn.allow_net(), "network read grants the runner net");
1971 assert_eq!(out["sandbox"]["runner"], "/opt/mandala-sandbox");
1972 assert_eq!(out["sandbox"]["net"], true);
1973 assert_eq!(
1974 out["sandbox"]["envelope"],
1975 wm_core::sandbox::ENVELOPE_SCHEMA
1976 );
1977 assert_eq!(sandbox.status()["dispatches"], 1);
1978 assert_eq!(sandbox.status()["degraded"], 0);
1979 }
1980
1981 #[tokio::test]
1982 async fn pipeline_degrades_loudly_when_runner_missing() {
1983 use crate::subprocess_sandbox::SubprocessSandbox;
1986 let sandbox = Arc::new(SubprocessSandbox::with_runner(None));
1987 let pipeline =
1988 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
1989 let mut ctx = Context::new(BrainWave::Gamma);
1990 let tool = TestTool::new(
1991 "spawn_tool",
1992 EffectRow {
1993 reads: vec![wm_core::Resource::Process],
1994 spawns: true,
1995 sandbox: Sandbox::Subprocess,
1996 ..Default::default()
1997 },
1998 )
1999 .with_output(serde_json::json!({"ok": true}));
2000
2001 let out = pipeline
2002 .dispatch(&tool, &mut ctx, Args::default())
2003 .await
2004 .expect("degrade keeps availability up");
2005 assert!(!ctx.spawn.is_active());
2006 assert!(
2007 out.get("sandbox").is_none(),
2008 "no runner means no confinement claim"
2009 );
2010 assert_eq!(sandbox.status()["dispatches"], 1);
2011 assert_eq!(sandbox.status()["degraded"], 1);
2012 }
2013
2014 #[tokio::test]
2015 async fn pipeline_surfaces_unmigrated_spawn_tools() {
2016 use crate::subprocess_sandbox::SubprocessSandbox;
2020 use std::path::PathBuf;
2021 use wm_core::sandbox::RunnerSource;
2022 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2023 wm_core::sandbox::RunnerInfo {
2024 path: PathBuf::from("/opt/mandala-sandbox"),
2025 source: RunnerSource::Env,
2026 },
2027 )));
2028 let pipeline =
2029 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2030 let mut ctx = Context::new(BrainWave::Gamma);
2031 let tool = TestTool::new(
2032 "legacy_git_tool",
2033 EffectRow {
2034 reads: vec![wm_core::Resource::Process],
2035 spawns: true,
2036 ..Default::default()
2037 },
2038 );
2039
2040 assert!(
2041 pipeline
2042 .dispatch(&tool, &mut ctx, Args::default())
2043 .await
2044 .is_ok()
2045 );
2046 assert!(!ctx.spawn.is_active());
2047 assert_eq!(sandbox.status()["unconfined_spawns"], 1);
2048 assert_eq!(sandbox.status()["dispatches"], 0);
2049 }
2050
2051 #[cfg(unix)]
2052 #[tokio::test]
2053 async fn declared_spawn_executes_through_the_runner_envelope() {
2054 use crate::subprocess_sandbox::SubprocessSandbox;
2058 use std::os::unix::fs::PermissionsExt;
2059 use wm_core::sandbox::{RunnerInfo, RunnerSource};
2060
2061 let dir = tempfile::tempdir().expect("tempdir");
2062 let marker = dir.path().join("envelope.json");
2063 let runner = dir.path().join("fake-runner");
2064 std::fs::write(
2065 &runner,
2066 format!(
2067 "#!/bin/sh\nprintf '%s' \"$2\" > '{}'\nexit 0\n",
2068 marker.display()
2069 ),
2070 )
2071 .expect("write fake runner");
2072 std::fs::set_permissions(&runner, std::fs::Permissions::from_mode(0o755))
2073 .expect("chmod fake runner");
2074
2075 struct SpawnProbeTool {
2076 effects: EffectRow,
2077 stats: ToolStats,
2078 }
2079 #[async_trait]
2080 impl Tool for SpawnProbeTool {
2081 fn name(&self) -> &str {
2082 "spawn_probe"
2083 }
2084 fn gana(&self) -> Gana {
2085 Gana::Heart
2086 }
2087 fn effects(&self) -> &EffectRow {
2088 &self.effects
2089 }
2090 async fn call(&self, ctx: &mut Context, _args: Args) -> Result<Output> {
2091 let out = ctx
2092 .spawn
2093 .command("printf", &["%s", "hi"])
2094 .output()
2095 .map_err(|e| CoreError::Tool(format!("spawn failed: {e}")))?;
2096 if !out.status.success() {
2097 return Err(CoreError::Tool("wrapped command failed".into()));
2098 }
2099 Ok(serde_json::json!({"ok": true}))
2100 }
2101 fn stats(&self) -> &ToolStats {
2102 &self.stats
2103 }
2104 }
2105
2106 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(RunnerInfo {
2107 path: runner,
2108 source: RunnerSource::Env,
2109 })));
2110 let pipeline =
2111 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2112 let mut ctx = Context::new(BrainWave::Gamma);
2113 let tool = SpawnProbeTool {
2114 effects: EffectRow {
2115 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2116 spawns: true,
2117 sandbox: Sandbox::Subprocess,
2118 ..Default::default()
2119 },
2120 stats: ToolStats::default(),
2121 };
2122 let out = pipeline
2123 .dispatch(&tool, &mut ctx, Args::default())
2124 .await
2125 .expect("wrapped spawn succeeds");
2126 assert_eq!(out["ok"], true);
2127 assert_eq!(out["sandbox"]["net"], true);
2128
2129 let captured = std::fs::read_to_string(&marker).expect("runner captured the envelope");
2130 let envelope: serde_json::Value = serde_json::from_str(&captured).expect("envelope JSON");
2131 assert_eq!(envelope["schema"], wm_core::sandbox::ENVELOPE_SCHEMA);
2132 assert_eq!(envelope["program"], "printf");
2133 assert_eq!(envelope["args"], serde_json::json!(["%s", "hi"]));
2134 assert_eq!(envelope["net"], true);
2135 }
2136
2137 #[tokio::test]
2138 async fn pipeline_secret_scan_warns_without_blocking() {
2139 use crate::secret_scan::SecretSampler;
2143 let sampler = Arc::new(SecretSampler::new(1));
2144 let pipeline =
2145 DispatchPipeline::with_defaults().with_secret_scan_option(Some(Arc::clone(&sampler)));
2146 let mut ctx = Context::new(BrainWave::Gamma);
2147 let tool = TestTool::new("key_tool", EffectRow::pure())
2148 .with_output(serde_json::json!({"data": "key=AKIAIOSFODNN7EXAMPLE"}));
2149 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2150 assert!(result.is_ok(), "warn-only scan must never block");
2151 assert_eq!(sampler.stats(), (1, 1, 1));
2152
2153 let clean = TestTool::new("clean_tool", EffectRow::pure())
2155 .with_output(serde_json::json!({"results": []}));
2156 assert!(
2157 pipeline
2158 .dispatch(&clean, &mut ctx, Args::default())
2159 .await
2160 .is_ok()
2161 );
2162 assert_eq!(sampler.stats(), (2, 2, 1));
2163 }
2164
2165 #[tokio::test]
2166 async fn pipeline_compartment_sandbox_blocks_read_from_karma() {
2167 let pipeline = DispatchPipeline::with_defaults();
2168 let mut ctx = Context::new(BrainWave::Gamma);
2169 ctx.compartment = Some("sandbox".into());
2170 let tool = TestTool::new(
2171 "read_tool",
2172 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2173 );
2174
2175 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2176 assert!(result.is_err());
2177 match result {
2178 Err(CoreError::Governance(msg)) => {
2179 assert!(msg.contains("sandbox"));
2180 assert!(msg.contains("karma"));
2181 }
2182 other => panic!("Expected Governance error, got {other:?}"),
2183 }
2184 }
2185
2186 #[tokio::test]
2187 async fn pipeline_compartment_sandbox_allows_write_to_tutorial() {
2188 let pipeline = DispatchPipeline::with_defaults();
2189 let mut ctx = Context::new(BrainWave::Gamma);
2190 ctx.compartment = Some("sandbox".into());
2191 let tool = TestTool::new(
2192 "write_tool",
2193 EffectRow {
2194 writes: vec![wm_core::Resource::Galaxy("tutorial".into())],
2195 ..Default::default()
2196 },
2197 );
2198
2199 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2200 assert!(result.is_ok());
2201 }
2202
2203 #[tokio::test]
2204 async fn pipeline_compartment_sandbox_allows_read_from_research() {
2205 let pipeline = DispatchPipeline::with_defaults();
2206 let mut ctx = Context::new(BrainWave::Gamma);
2207 ctx.compartment = Some("sandbox".into());
2208 let tool = TestTool::new(
2209 "read_tool",
2210 EffectRow::read_only(vec![wm_core::Resource::Galaxy("research".into())]),
2211 );
2212
2213 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2214 assert!(result.is_ok());
2215 }
2216
2217 #[tokio::test]
2218 async fn pipeline_compartment_production_blocks_read_from_karma() {
2219 let pipeline = DispatchPipeline::with_defaults();
2220 let mut ctx = Context::new(BrainWave::Gamma);
2221 ctx.compartment = Some("production".into());
2222 let tool = TestTool::new(
2223 "read_tool",
2224 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2225 );
2226
2227 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2228 assert!(result.is_err());
2229 match result {
2230 Err(CoreError::Governance(msg)) => {
2231 assert!(msg.contains("production"));
2232 assert!(msg.contains("karma"));
2233 }
2234 other => panic!("Expected Governance error, got {other:?}"),
2235 }
2236 }
2237
2238 #[tokio::test]
2239 async fn pipeline_compartment_production_allows_write_to_codex() {
2240 let pipeline = DispatchPipeline::with_defaults();
2241 let mut ctx = Context::new(BrainWave::Gamma);
2242 ctx.compartment = Some("production".into());
2243 let tool = TestTool::new(
2244 "write_tool",
2245 EffectRow {
2246 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2247 ..Default::default()
2248 },
2249 );
2250
2251 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2252 assert!(result.is_ok());
2253 }
2254
2255 #[tokio::test]
2256 async fn pipeline_compartment_secure_allows_write_to_codex() {
2257 let pipeline = DispatchPipeline::with_defaults();
2258 let mut ctx = Context::new(BrainWave::Gamma);
2259 ctx.compartment = Some("secure".into());
2260 let tool = TestTool::new(
2261 "write_tool",
2262 EffectRow {
2263 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2264 ..Default::default()
2265 },
2266 );
2267
2268 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2269 assert!(result.is_ok());
2270 }
2271
2272 #[tokio::test]
2273 async fn pipeline_compartment_secure_blocks_read_from_karma() {
2274 let pipeline = DispatchPipeline::with_defaults();
2275 let mut ctx = Context::new(BrainWave::Gamma);
2276 ctx.compartment = Some("secure".into());
2277 let tool = TestTool::new(
2278 "read_tool",
2279 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2280 );
2281
2282 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2283 assert!(result.is_err());
2284 match result {
2285 Err(CoreError::Governance(msg)) => {
2286 assert!(msg.contains("secure"));
2287 assert!(msg.contains("karma"));
2288 }
2289 other => panic!("Expected Governance error, got {other:?}"),
2290 }
2291 }
2292
2293 fn rules_with(max_writes: u32, max_repeats: u32) -> Arc<ResourceRules> {
2296 Arc::new(ResourceRules::new(ResourceRulesConfig {
2297 max_writes_per_minute: max_writes,
2298 max_spawns_per_minute: 100,
2299 max_network_per_minute: 100,
2300 novelty_window: 50,
2301 max_repeats,
2302 require_human_review: false,
2303 }))
2304 }
2305
2306 #[tokio::test]
2307 async fn pipeline_resource_rules_budget_exceeding_write_refused() {
2308 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(2, 1000));
2309 let mut ctx = Context::new(BrainWave::Gamma);
2310 let tool = TestTool::new(
2311 "write_tool",
2312 EffectRow {
2313 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2314 ..Default::default()
2315 },
2316 );
2317
2318 assert!(
2319 pipeline
2320 .dispatch(&tool, &mut ctx, Args::default())
2321 .await
2322 .is_ok(),
2323 "first write within budget"
2324 );
2325 assert!(
2326 pipeline
2327 .dispatch(&tool, &mut ctx, Args::default())
2328 .await
2329 .is_ok(),
2330 "second write within budget"
2331 );
2332 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2333 assert!(result.is_err(), "third write must exceed the budget");
2334 match result {
2335 Err(CoreError::Governance(msg)) => {
2336 assert!(msg.contains("resource rules"), "got: {msg}");
2337 assert!(msg.contains("writes"), "got: {msg}");
2338 }
2339 other => panic!("Expected Governance error, got {other:?}"),
2340 }
2341 }
2342
2343 #[tokio::test]
2344 async fn pipeline_resource_rules_novelty_flag_reaches_response() {
2345 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(1000, 1));
2346 let mut ctx = Context::new(BrainWave::Gamma);
2347 let tool = TestTool::new("read_tool", EffectRow::pure())
2348 .with_output(serde_json::json!({"status": "ok"}));
2349
2350 let first = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2351 assert!(first.is_ok());
2352 assert!(
2353 first.unwrap().get("resource_flags").is_none(),
2354 "first call is novel — no flag"
2355 );
2356
2357 let second = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2358 let output = second.expect("repeated call must still succeed (flag, not block)");
2359 let flags = output
2360 .get("resource_flags")
2361 .and_then(|f| f.as_array())
2362 .expect("novelty flag must reach the response");
2363 assert_eq!(flags.len(), 1);
2364 assert!(flags[0].as_str().unwrap().contains("not novel"));
2365 }
2366
2367 #[tokio::test]
2368 async fn pipeline_resource_rules_blocks_unapproved_autonomous() {
2369 let rules = Arc::new(ResourceRules::default());
2370 rules.set_user_initiated(false);
2371 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2372 let mut ctx = Context::new(BrainWave::Gamma);
2373 let tool = TestTool::new(
2374 "memory.consolidate",
2375 EffectRow {
2376 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2377 ..Default::default()
2378 },
2379 );
2380
2381 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2382 assert!(result.is_err());
2383 match result {
2384 Err(CoreError::Governance(msg)) => {
2385 assert!(msg.contains("human review"), "got: {msg}");
2386 }
2387 other => panic!("Expected Governance error, got {other:?}"),
2388 }
2389 }
2390
2391 #[tokio::test]
2392 async fn pipeline_resource_rules_allows_approved_autonomous() {
2393 let rules = Arc::new(ResourceRules::default());
2394 rules.set_user_initiated(false);
2395 rules.set_human_approved(true);
2396 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2397 let mut ctx = Context::new(BrainWave::Gamma);
2398 let tool = TestTool::new(
2399 "memory.consolidate",
2400 EffectRow {
2401 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2402 ..Default::default()
2403 },
2404 );
2405
2406 let result = pipeline
2407 .dispatch(
2408 &tool,
2409 &mut ctx,
2410 serde_json::json!({"purpose": "consolidate codex"}),
2411 )
2412 .await;
2413 assert!(result.is_ok());
2414 }
2415
2416 #[tokio::test]
2417 async fn pipeline_resource_rules_user_initiated_writes_allowed_by_default() {
2418 let pipeline = DispatchPipeline::with_defaults()
2420 .with_resource_rules(Arc::new(ResourceRules::default()));
2421 let mut ctx = Context::new(BrainWave::Gamma);
2422 let tool = TestTool::new(
2423 "write_tool",
2424 EffectRow {
2425 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2426 ..Default::default()
2427 },
2428 );
2429
2430 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2431 assert!(result.is_ok());
2432 }
2433
2434 #[tokio::test]
2437 async fn pipeline_runtime_satya_blocks_citta_write_without_read() {
2438 let pipeline = DispatchPipeline::with_defaults();
2439 let mut ctx = Context::new(BrainWave::Gamma);
2440 let tool = TestTool::new(
2441 "memory.create",
2442 EffectRow {
2443 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2444 ..Default::default()
2445 },
2446 );
2447
2448 let result = pipeline
2449 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2450 .await;
2451 assert!(result.is_err());
2452 match result {
2453 Err(CoreError::Governance(msg)) => {
2454 assert!(msg.contains("VIOLATION_SATYA"), "got: {msg}");
2455 }
2456 other => panic!("Expected Governance error, got {other:?}"),
2457 }
2458 }
2459
2460 #[tokio::test]
2461 async fn pipeline_runtime_satya_allows_citta_write_with_read_evidence() {
2462 let pipeline = DispatchPipeline::with_defaults();
2463 let mut ctx = Context::new(BrainWave::Gamma);
2464 let tool = TestTool::new(
2465 "consolidate_tool",
2466 EffectRow {
2467 reads: vec![wm_core::Resource::Galaxy("citta".into())],
2468 writes: vec![wm_core::Resource::Galaxy("citta".into())],
2469 ..Default::default()
2470 },
2471 );
2472
2473 let result = pipeline
2474 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2475 .await;
2476 assert!(result.is_ok());
2477 }
2478
2479 #[tokio::test]
2480 async fn pipeline_runtime_satya_allows_non_citta_runtime_galaxy() {
2481 let pipeline = DispatchPipeline::with_defaults();
2482 let mut ctx = Context::new(BrainWave::Gamma);
2483 let tool = TestTool::new(
2484 "memory.create",
2485 EffectRow {
2486 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2487 ..Default::default()
2488 },
2489 );
2490
2491 let result = pipeline
2492 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "research"}))
2493 .await;
2494 assert!(result.is_ok());
2495 }
2496
2497 #[tokio::test]
2500 async fn pipeline_write_audit_detects_misdeclaring_tool() {
2501 let tmp = tempfile::tempdir().unwrap();
2502 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2503 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2504 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2505 let mut ctx = Context::new(BrainWave::Gamma);
2506
2507 let tool = TestTool::new("sneaky_tool", EffectRow::pure()).with_store(store);
2509
2510 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2511 assert!(result.is_ok());
2512
2513 let mis = journal.misdeclarations().unwrap();
2514 assert!(!mis.is_empty(), "misdeclaring tool must be detected");
2515 assert_eq!(mis.last().unwrap().tool, "sneaky_tool");
2516 assert!(mis.last().unwrap().undeclared_mutation());
2517 }
2518
2519 #[tokio::test]
2520 async fn pipeline_write_audit_skips_meta_router() {
2521 let tmp = tempfile::tempdir().unwrap();
2522 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2523 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2524 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2525 let mut ctx = Context::new(BrainWave::Gamma);
2526
2527 let tool = TestTool::new("wm", EffectRow::pure()).with_store(store);
2531 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2532 assert!(result.is_ok());
2533
2534 let mis = journal.misdeclarations().unwrap();
2535 assert!(
2536 mis.iter().all(|m| m.tool != "wm"),
2537 "meta router must not appear as a misdeclaration: {mis:?}"
2538 );
2539 }
2540
2541 #[tokio::test]
2542 async fn pipeline_write_audit_records_declared_writes_with_identity() {
2543 let tmp = tempfile::tempdir().unwrap();
2544 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2545 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2546 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2547 let mut ctx = Context::new(BrainWave::Gamma);
2548
2549 let tool = TestTool::new(
2550 "honest_tool",
2551 EffectRow {
2552 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2553 ..Default::default()
2554 },
2555 )
2556 .with_store(store);
2557
2558 let args = serde_json::json!({"id": "abc-123", "content_hash": "hash-xyz"});
2559 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2560 assert!(result.is_ok());
2561
2562 let entries = journal.scan_entries().unwrap();
2563 assert_eq!(entries.len(), 1);
2564 let entry = &entries[0];
2565 assert!(entry.declared_writes);
2566 assert!(entry.store_write_delta >= 1);
2567 assert_eq!(entry.memory_id.as_deref(), Some("abc-123"));
2568 assert_eq!(entry.content_hash.as_deref(), Some("hash-xyz"));
2569 assert!(journal.misdeclarations().unwrap().is_empty());
2570 }
2571
2572 #[tokio::test]
2573 async fn pipeline_write_audit_captures_actor_identity() {
2574 let tmp = tempfile::tempdir().unwrap();
2577 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2578 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2579 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2580 let mut ctx = Context::new(BrainWave::Gamma);
2581 ctx.session_id = Some(uuid::Uuid::nil());
2582 ctx.user_id = Some("agent-b".to_string());
2583 ctx.compartment = Some("production".to_string());
2584
2585 let tool = TestTool::new(
2586 "honest_tool",
2587 EffectRow {
2588 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2589 ..Default::default()
2590 },
2591 )
2592 .with_store(store);
2593
2594 let result = pipeline
2595 .dispatch(&tool, &mut ctx, serde_json::json!({"id": "abc-123"}))
2596 .await;
2597 assert!(result.is_ok());
2598
2599 let entries = journal.scan_entries().unwrap();
2600 assert_eq!(entries.len(), 1);
2601 let entry = &entries[0];
2602 assert_eq!(
2603 entry.actor_session.as_deref(),
2604 Some(uuid::Uuid::nil().to_string().as_str())
2605 );
2606 assert_eq!(entry.actor_user.as_deref(), Some("agent-b"));
2607 assert_eq!(entry.actor_compartment.as_deref(), Some("production"));
2608 }
2609
2610 #[tokio::test]
2611 async fn pipeline_write_audit_read_dispatch_not_flagged_after_external_writes() {
2612 let tmp = tempfile::tempdir().unwrap();
2617 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2618 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2619 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2620 let mut ctx = Context::new(BrainWave::Gamma);
2621
2622 for i in 0..3 {
2624 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("other session {i}"));
2625 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
2626 }
2627
2628 let read_tool = TestTool::new("memory.search", EffectRow::pure());
2629 let result = pipeline
2630 .dispatch(&read_tool, &mut ctx, Args::default())
2631 .await;
2632 assert!(result.is_ok());
2633
2634 let mis = journal.misdeclarations().unwrap();
2635 assert!(
2636 mis.is_empty(),
2637 "read-only dispatch must not inherit the other session's writes: {mis:?}"
2638 );
2639 let entries = journal.scan_entries().unwrap();
2640 assert_eq!(entries.last().unwrap().store_write_delta, 0);
2641 }
2642
2643 #[tokio::test]
2646 async fn pipeline_firebreak_forbidden_blocks_even_with_confirm() {
2647 let pipeline = DispatchPipeline::with_defaults();
2648 let mut ctx = Context::new(BrainWave::Gamma);
2649 let tool = TestTool::new(
2650 "destructive_tool",
2651 EffectRow {
2652 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2653 destructive: true,
2654 ..Default::default()
2655 },
2656 );
2657
2658 let result = pipeline
2659 .dispatch(
2660 &tool,
2661 &mut ctx,
2662 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
2663 )
2664 .await;
2665 match result {
2666 Err(CoreError::Governance(msg)) => {
2667 assert!(msg.contains("FORBIDDEN"), "got: {msg}");
2668 assert!(msg.contains("never allowed"), "got: {msg}");
2669 }
2670 other => panic!("Expected Governance error, got {other:?}"),
2671 }
2672 }
2673
2674 #[tokio::test]
2675 async fn pipeline_firebreak_scope_law_blocks_unscoped_destructive() {
2676 let pipeline = DispatchPipeline::with_defaults();
2677 let mut ctx = Context::new(BrainWave::Gamma);
2678 let tool = TestTool::new(
2680 "memory.delete",
2681 EffectRow {
2682 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2683 destructive: true,
2684 ..Default::default()
2685 },
2686 );
2687
2688 let result = pipeline
2689 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
2690 .await;
2691 match result {
2692 Err(CoreError::Governance(msg)) => {
2693 assert!(msg.contains("no explicit scope"), "got: {msg}");
2694 assert!(msg.contains("id"), "names the scope field: {msg}");
2695 }
2696 other => panic!("Expected Governance error, got {other:?}"),
2697 }
2698 }
2699
2700 #[tokio::test]
2701 async fn pipeline_firebreak_scope_law_allows_scoped_destructive() {
2702 let pipeline = DispatchPipeline::with_defaults();
2703 let mut ctx = Context::new(BrainWave::Gamma);
2704 let tool = TestTool::new(
2705 "memory.delete",
2706 EffectRow {
2707 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2708 destructive: true,
2709 ..Default::default()
2710 },
2711 );
2712
2713 let result = pipeline
2714 .dispatch(
2715 &tool,
2716 &mut ctx,
2717 serde_json::json!({"confirm": true, "id": "0f0e0d0c-0000-0000-0000-000000000000"}),
2718 )
2719 .await;
2720 assert!(result.is_ok());
2721 }
2722
2723 #[tokio::test]
2724 async fn pipeline_firebreak_caution_disclosed_in_response() {
2725 let pipeline = DispatchPipeline::with_defaults();
2726 let mut ctx = Context::new(BrainWave::Gamma);
2727 let tool = TestTool::new(
2728 "galaxy.transfer",
2729 EffectRow {
2730 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2731 destructive: true,
2732 ..Default::default()
2733 },
2734 )
2735 .with_output(serde_json::json!({"status": "success"}));
2736
2737 let result = pipeline
2738 .dispatch(
2739 &tool,
2740 &mut ctx,
2741 serde_json::json!({"confirm": true, "from_galaxy": "codex", "note": "mv old new"}),
2742 )
2743 .await;
2744 let output = result.expect("caution must not block");
2745 let advisories = output
2746 .get("firebreak")
2747 .and_then(|f| f.get("advisories"))
2748 .and_then(|a| a.as_array())
2749 .expect("advisories must reach the response");
2750 assert_eq!(advisories.len(), 1);
2751 }
2752
2753 #[tokio::test]
2754 async fn pipeline_firebreak_dangerous_escalates_off_confirm_gate() {
2755 let pipeline = DispatchPipeline::with_defaults();
2759 let mut ctx = Context::new(BrainWave::Gamma);
2760 let tool = TestTool::new(
2761 "spawn_tool",
2762 EffectRow {
2763 spawns: true,
2764 ..Default::default()
2765 },
2766 );
2767
2768 let blocked = pipeline
2769 .dispatch(
2770 &tool,
2771 &mut ctx,
2772 serde_json::json!({"cmd": "sudo rm -r /tmp/build"}),
2773 )
2774 .await;
2775 match blocked {
2776 Err(CoreError::Governance(msg)) => {
2777 assert!(msg.contains("dangerous"), "got: {msg}");
2778 assert!(msg.contains("confirm"), "got: {msg}");
2779 }
2780 other => panic!("Expected Governance error, got {other:?}"),
2781 }
2782
2783 let allowed = pipeline
2784 .dispatch(
2785 &tool,
2786 &mut ctx,
2787 serde_json::json!({"cmd": "sudo rm -r /tmp/build", "confirm": true}),
2788 )
2789 .await;
2790 assert!(allowed.is_ok());
2791 }
2792
2793 #[tokio::test]
2794 async fn pipeline_firebreak_never_scans_prose() {
2795 let pipeline = DispatchPipeline::with_defaults();
2798 let mut ctx = Context::new(BrainWave::Gamma);
2799 let tool = TestTool::new(
2800 "memory.create",
2801 EffectRow {
2802 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2803 ..Default::default()
2804 },
2805 );
2806
2807 let result = pipeline
2808 .dispatch(
2809 &tool,
2810 &mut ctx,
2811 serde_json::json!({"content": "incident: operator ran rm -rf / on the store"}),
2812 )
2813 .await;
2814 assert!(result.is_ok(), "prose is never vetoed");
2815 }
2816
2817 #[tokio::test]
2818 async fn pipeline_firebreak_disarmable_per_pipeline() {
2819 let pipeline = DispatchPipeline::with_defaults()
2820 .with_firebreak_option(None::<Arc<wm_governance::Firebreak>>);
2821 let mut ctx = Context::new(BrainWave::Gamma);
2822 let tool = TestTool::new(
2823 "destructive_tool",
2824 EffectRow {
2825 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2826 destructive: true,
2827 ..Default::default()
2828 },
2829 );
2830
2831 let result = pipeline
2833 .dispatch(&tool, &mut ctx, serde_json::json!({}))
2834 .await;
2835 assert!(result.is_err());
2836
2837 let result = pipeline
2839 .dispatch(
2840 &tool,
2841 &mut ctx,
2842 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
2843 )
2844 .await;
2845 assert!(result.is_ok(), "disarmed pipeline must not veto");
2846 }
2847
2848 #[tokio::test]
2849 async fn pipeline_write_audit_records_destructive_confirm() {
2850 let tmp = tempfile::tempdir().unwrap();
2853 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2854 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2855 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2856 let mut ctx = Context::new(BrainWave::Gamma);
2857
2858 let tool = TestTool::new(
2859 "memory.delete",
2860 EffectRow {
2861 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2862 destructive: true,
2863 ..Default::default()
2864 },
2865 )
2866 .with_store(store);
2867
2868 let result = pipeline
2869 .dispatch(
2870 &tool,
2871 &mut ctx,
2872 serde_json::json!({"confirm": true, "id": "abc-123"}),
2873 )
2874 .await;
2875 assert!(result.is_ok());
2876
2877 let entries = journal.scan_entries().unwrap();
2878 assert_eq!(entries.len(), 1);
2879 assert_eq!(
2880 entries[0].confirmed,
2881 Some(true),
2882 "destructive entry must record the confirm"
2883 );
2884 }
2885
2886 #[tokio::test]
2889 async fn pipeline_compartment_production_blocks_runtime_galaxy_write_bypass() {
2890 let pipeline = DispatchPipeline::with_defaults();
2893 let mut ctx = Context::new(BrainWave::Gamma);
2894 ctx.compartment = Some("production".into());
2895 let tool = TestTool::new(
2896 "memory_update",
2897 EffectRow {
2898 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2899 ..Default::default()
2900 },
2901 );
2902
2903 let args = serde_json::json!({"galaxy": "karma"});
2904 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2905 assert!(result.is_err());
2906 match result {
2907 Err(CoreError::Governance(msg)) => {
2908 assert!(msg.contains("production"));
2909 assert!(msg.contains("karma"));
2910 assert!(msg.contains("runtime"));
2911 }
2912 other => panic!("Expected Governance error, got {other:?}"),
2913 }
2914 }
2915
2916 #[tokio::test]
2917 async fn pipeline_compartment_production_blocks_runtime_galaxy_read_bypass() {
2918 let pipeline = DispatchPipeline::with_defaults();
2921 let mut ctx = Context::new(BrainWave::Gamma);
2922 ctx.compartment = Some("production".into());
2923 let tool = TestTool::new(
2924 "memory_read",
2925 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
2926 );
2927
2928 let args = serde_json::json!({"galaxy": "karma"});
2929 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2930 assert!(result.is_err());
2931 match result {
2932 Err(CoreError::Governance(msg)) => {
2933 assert!(msg.contains("production"));
2934 assert!(msg.contains("karma"));
2935 assert!(msg.contains("runtime"));
2936 }
2937 other => panic!("Expected Governance error, got {other:?}"),
2938 }
2939 }
2940
2941 #[tokio::test]
2942 async fn pipeline_compartment_production_allows_runtime_galaxy_same_as_declared() {
2943 let pipeline = DispatchPipeline::with_defaults();
2946 let mut ctx = Context::new(BrainWave::Gamma);
2947 ctx.compartment = Some("production".into());
2948 let tool = TestTool::new(
2949 "memory_read",
2950 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
2951 );
2952
2953 let args = serde_json::json!({"galaxy": "codex"});
2954 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2955 assert!(result.is_ok());
2956 }
2957
2958 #[tokio::test]
2959 async fn pipeline_compartment_no_restriction_allows_runtime_galaxy() {
2960 let pipeline = DispatchPipeline::with_defaults();
2962 let mut ctx = Context::new(BrainWave::Gamma);
2963 let tool = TestTool::new(
2964 "memory_read",
2965 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
2966 );
2967
2968 let args = serde_json::json!({"galaxy": "karma"});
2969 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2970 assert!(result.is_ok());
2971 }
2972
2973 #[tokio::test]
2974 async fn pipeline_compartment_production_allows_runtime_memory_galaxy() {
2975 let pipeline = DispatchPipeline::with_defaults();
2978 let mut ctx = Context::new(BrainWave::Gamma);
2979 ctx.compartment = Some("production".into());
2980 let tool = TestTool::new(
2981 "memory_read",
2982 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
2983 );
2984
2985 let args = serde_json::json!({"galaxy": "research"});
2986 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2987 assert!(result.is_ok());
2988 }
2989
2990 #[tokio::test]
2991 async fn pipeline_compartment_production_blocks_runtime_system_galaxy() {
2992 let pipeline = DispatchPipeline::with_defaults();
2995 let mut ctx = Context::new(BrainWave::Gamma);
2996 ctx.compartment = Some("production".into());
2997 let tool = TestTool::new(
2998 "memory_read",
2999 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3000 );
3001
3002 let args = serde_json::json!({"galaxy": "karma"});
3003 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3004 assert!(result.is_err());
3005 match result {
3006 Err(CoreError::Governance(msg)) => {
3007 assert!(msg.contains("production"));
3008 assert!(msg.contains("karma"));
3009 assert!(msg.contains("runtime"));
3010 }
3011 other => panic!("Expected Governance error, got {other:?}"),
3012 }
3013 }
3014
3015 #[tokio::test]
3016 async fn benchmark_pipeline_overhead() {
3017 let pipeline = DispatchPipeline::with_defaults();
3018 let tool = TestTool::new("bench_tool", EffectRow::pure());
3019 let args = Args::default();
3020
3021 for _ in 0..100 {
3023 let mut ctx = Context::new(BrainWave::Gamma);
3024 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3025 }
3026
3027 let n = 10_000;
3029 let start = std::time::Instant::now();
3030 for _ in 0..n {
3031 let mut ctx = Context::new(BrainWave::Gamma);
3032 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3033 }
3034 let pipeline_ns = start.elapsed().as_nanos() / n;
3035
3036 let start = std::time::Instant::now();
3038 for _ in 0..n {
3039 let mut ctx = Context::new(BrainWave::Gamma);
3040 let _ = tool.call(&mut ctx, args.clone()).await;
3041 }
3042 let direct_ns = start.elapsed().as_nanos() / n;
3043
3044 let overhead_ns = pipeline_ns.saturating_sub(direct_ns);
3045 println!(
3046 "\n Pipeline: {pipeline_ns} ns/call | Direct: {direct_ns} ns/call | Overhead: {overhead_ns} ns/call"
3047 );
3048
3049 #[cfg(not(debug_assertions))]
3053 assert!(
3054 overhead_ns < 5_000,
3055 "Pipeline overhead {overhead_ns} ns/call exceeds 5µs budget"
3056 );
3057 }
3058}