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 "homeostasis limit (self-model confidence): tool '{}' requires write access but 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()) {
637 return Err(CoreError::RateLimited(format!(
638 "request rate limit (per-tool dispatch governor): '{}' — retry after {}ms",
639 tool.name(),
640 retry_after_ms
641 )));
642 }
643
644 if self.circuit_breakers.is_open(tool.name()) {
646 let retry_after_ms = self
647 .circuit_breakers
648 .remaining_cooldown(tool.name())
649 .as_millis();
650 return Err(CoreError::CircuitBreaker(format!(
651 "{} — repeated execution failures opened the breaker; retry after {}ms",
652 tool.name(),
653 retry_after_ms
654 )));
655 }
656
657 let confirm_gated = if tool.effects().destructive {
660 if !confirmed {
661 return Err(CoreError::Governance(format!(
662 "tool '{}' is destructive — pass `\"confirm\": true` in args to proceed",
663 tool.name()
664 )));
665 }
666 Some(true)
669 } else {
670 None
671 };
672
673 let mut firebreak_advisories: Vec<String> = Vec::new();
681 if let Some(ref firebreak) = self.firebreak {
682 match firebreak.enforce(tool.name(), tool.effects(), &args) {
683 FirebreakOutcome::Blocked(reason) => {
684 tracing::warn!(tool = tool.name(), reason = %reason, "firebreak VETO");
685 return Err(CoreError::Governance(reason));
686 }
687 FirebreakOutcome::Proceed { advisories } if !advisories.is_empty() => {
688 tracing::info!(tool = tool.name(), advisories = ?advisories, "firebreak advisories");
689 firebreak_advisories = advisories;
690 }
691 FirebreakOutcome::Proceed { .. } => {}
692 }
693 }
694
695 let has_runtime_galaxy = args
709 .get("galaxy")
710 .and_then(serde_json::Value::as_str)
711 .is_some_and(|g| !g.is_empty());
712 let mut checked_galaxies: Vec<wm_core::Galaxy> = Vec::new();
713
714 if !has_runtime_galaxy {
715 for resource in &tool.effects().reads {
716 if let wm_core::Resource::Galaxy(name) = resource {
717 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
718 if !ctx.can_access_galaxy(galaxy) {
719 return Err(CoreError::Governance(format!(
720 "compartment '{}' cannot read galaxy '{}' (tool '{}')",
721 ctx.compartment.as_deref().unwrap_or("none"),
722 name,
723 tool.name()
724 )));
725 }
726 checked_galaxies.push(galaxy);
727 }
728 }
729 }
730 for resource in &tool.effects().writes {
731 if let wm_core::Resource::Galaxy(name) = resource {
732 if let Some(galaxy) = wm_core::Galaxy::from_db_name(name) {
733 if !ctx.can_write_galaxy(galaxy) {
734 return Err(CoreError::Governance(format!(
735 "compartment '{}' cannot write to galaxy '{}' (tool '{}')",
736 ctx.compartment.as_deref().unwrap_or("none"),
737 name,
738 tool.name()
739 )));
740 }
741 checked_galaxies.push(galaxy);
742 }
743 }
744 }
745 }
746
747 if let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str) {
749 if !galaxy_str.is_empty() {
750 if let Some(runtime_galaxy) = wm_core::Galaxy::from_db_name(galaxy_str) {
751 if !checked_galaxies.contains(&runtime_galaxy) {
752 let has_writes = !tool.effects().writes.is_empty();
754 if has_writes {
755 if !ctx.can_write_galaxy(runtime_galaxy) {
756 return Err(CoreError::Governance(format!(
757 "compartment '{}' cannot write to galaxy '{}' (tool '{}' runtime arg)",
758 ctx.compartment.as_deref().unwrap_or("none"),
759 galaxy_str,
760 tool.name()
761 )));
762 }
763 } else if !ctx.can_access_galaxy(runtime_galaxy) {
764 return Err(CoreError::Governance(format!(
765 "compartment '{}' cannot read galaxy '{}' (tool '{}' runtime arg)",
766 ctx.compartment.as_deref().unwrap_or("none"),
767 galaxy_str,
768 tool.name()
769 )));
770 }
771 }
772 }
773 }
774 }
775
776 if !tool.effects().writes.is_empty()
782 && let Some(galaxy_str) = args.get("galaxy").and_then(serde_json::Value::as_str)
783 && galaxy_str == "citta"
784 && !tool
785 .effects()
786 .reads
787 .iter()
788 .any(|r| matches!(r, wm_core::Resource::Galaxy(g) if g == "citta"))
789 {
790 return Err(CoreError::Governance(
791 "VIOLATION_SATYA: writing to citta (runtime galaxy) without reading — memory fabrication is forbidden"
792 .to_string(),
793 ));
794 }
795
796 let args_memory_id = first_str(&args, &["id", "memory_id", "memory"]);
804 let args_content_hash = first_str(&args, &["content_hash", "hash", "sha256"]);
805 let args_digest = wm_governance::args_digest(tool.name(), &args);
810 if let Some(ref flight) = self.flight_recorder {
816 if let Err(e) = flight.record(tool.name(), &args) {
817 tracing::warn!(error = %e, "Flight recorder capture failed (replay will refuse)");
818 }
819 }
820 let write_audit_baseline = self
821 .write_audit
822 .as_ref()
823 .map_or(0, |j| j.dispatch_baseline());
824 let mut spawn_disclosure: Option<serde_json::Value> = None;
830 if let Some(sb) = self.subprocess_sandbox.as_deref() {
831 if crate::subprocess_sandbox::SubprocessSandbox::declared(tool.effects()) {
832 let policy = sb.policy_for(tool.effects());
833 if policy.is_active() {
834 let mut disclosure = serde_json::json!({
835 "net": policy.allow_net(),
836 "envelope": wm_core::sandbox::ENVELOPE_SCHEMA,
837 });
838 if let Some(runner) = policy.runner()
839 && let Some(obj) = disclosure.as_object_mut()
840 {
841 obj.insert(
842 "runner".to_string(),
843 serde_json::Value::String(runner.display().to_string()),
844 );
845 }
846 sb.note_confined();
847 spawn_disclosure = Some(disclosure);
848 } else {
849 sb.note_degraded(tool.name());
850 }
851 ctx.spawn = policy;
852 } else if tool.effects().spawns {
853 sb.note_unconfined_spawn(tool.name());
854 }
855 }
856 let result = if crate::sandbox_exec::ScopedSandboxExecutor::handles(tool)
860 && let Some(executor) = self.sandbox_exec.as_deref()
861 {
862 executor.run(tool, ctx, args)
863 } else if let Some(timeout) = self.dispatch_timeout {
864 if let Ok(res) = tokio::time::timeout(timeout, tool.call(ctx, args)).await {
865 res
866 } else {
867 tracing::error!(
868 tool = tool.name(),
869 timeout_ms = timeout.as_millis(),
870 "tool dispatch timed out"
871 );
872 self.circuit_breakers.record_failure(tool.name());
873 return Err(CoreError::Tool(format!(
874 "tool '{}' timed out after {}ms",
875 tool.name(),
876 timeout.as_millis()
877 )));
878 }
879 } else {
880 tool.call(ctx, args).await
881 };
882 let elapsed = start.elapsed();
883
884 if let Some(ref scanner) = self.secret_scan {
889 if let Ok(ref output) = result {
890 scanner.scan(tool.name(), output);
891 }
892 }
893
894 let result = match (result, novelty_flag) {
896 (Ok(mut output), Some(flag)) => {
897 if let serde_json::Value::Object(ref mut map) = output {
898 match map.get_mut("resource_flags") {
899 Some(serde_json::Value::Array(arr)) => {
900 arr.push(serde_json::Value::String(flag));
901 }
902 Some(_) => {}
903 None => {
904 map.insert(
905 "resource_flags".to_string(),
906 serde_json::Value::Array(vec![serde_json::Value::String(flag)]),
907 );
908 }
909 }
910 }
911 Ok(output)
912 }
913 (result, _) => result,
914 };
915
916 let result = match (result, gate_disclosure) {
919 (Ok(mut output), Some(disclosure)) => {
920 if let serde_json::Value::Object(ref mut map) = output {
921 map.insert("write_gate".to_string(), disclosure);
922 }
923 Ok(output)
924 }
925 (result, _) => result,
926 };
927
928 let result = match (result, firebreak_advisories) {
932 (Ok(mut output), advisories) if !advisories.is_empty() => {
933 if let serde_json::Value::Object(ref mut map) = output {
934 map.insert(
935 "firebreak".to_string(),
936 serde_json::json!({ "advisories": advisories }),
937 );
938 }
939 Ok(output)
940 }
941 (result, _) => result,
942 };
943
944 let result = match (result, spawn_disclosure) {
947 (Ok(mut output), Some(disclosure)) => {
948 if let serde_json::Value::Object(ref mut map) = output {
949 map.insert("sandbox".to_string(), disclosure);
950 }
951 Ok(output)
952 }
953 (result, _) => result,
954 };
955
956 if let Ok(output) = &result {
958 tool.stats().record_success(elapsed, elapsed);
959 self.circuit_breakers.record_success(tool.name());
960
961 if let Some(ref ledger) = self.karma_ledger {
962 let declared_writes = !tool.effects().writes.is_empty();
963 let actual_writes = output
964 .get("writes")
965 .and_then(|w| w.as_array())
966 .map_or(0, |a| a.len() as u32);
967 if let Err(e) = ledger.record(tool.name(), declared_writes, actual_writes, true) {
968 tracing::warn!(error = %e, "Karma ledger record failed");
969 }
970 ctx.karma_debt = ledger.total_debt();
971 }
972
973 if let Some(ref journal) = self.write_audit {
974 let declared_writes = !tool.effects().writes.is_empty();
975 record_write_audit(
976 journal,
977 write_audit_baseline,
978 tool.name(),
979 wm_governance::ActorIdentity::from_context(ctx),
980 declared_writes,
981 args_memory_id.as_deref(),
982 args_content_hash.as_deref(),
983 Some(args_digest),
984 output,
985 true,
986 confirm_gated,
987 );
988 }
989 } else {
990 tool.stats().record_failure(elapsed);
991 if let Err(err) = &result {
995 if err.counts_as_breaker_failure() {
996 self.circuit_breakers.record_failure(tool.name());
997 }
998 }
999
1000 if let Some(ref ledger) = self.karma_ledger {
1001 let declared_writes = !tool.effects().writes.is_empty();
1002 if let Err(ke) = ledger.record(tool.name(), declared_writes, 0, false) {
1003 tracing::warn!(error = %ke, "Karma ledger record failed");
1004 }
1005 ctx.karma_debt = ledger.total_debt();
1006 }
1007
1008 if let Some(ref journal) = self.write_audit {
1009 let declared_writes = !tool.effects().writes.is_empty();
1010 record_write_audit(
1011 journal,
1012 write_audit_baseline,
1013 tool.name(),
1014 wm_governance::ActorIdentity::from_context(ctx),
1015 declared_writes,
1016 args_memory_id.as_deref(),
1017 args_content_hash.as_deref(),
1018 Some(args_digest),
1019 &serde_json::Value::Null,
1020 false,
1021 confirm_gated,
1022 );
1023 }
1024 }
1025
1026 if let Some(ref registry) = self.gana_registry {
1028 if let Ok(mut reg) = registry.lock() {
1029 let gana = tool.gana();
1030 reg.record_usage(gana, result.is_ok());
1031 if let Some(prev) = ctx.last_gana {
1033 reg.record_co_usage(prev, gana);
1034 }
1035 ctx.last_gana = Some(gana);
1036 }
1037 }
1038
1039 result
1040 }
1041
1042 pub async fn dispatch_by_name(
1047 &self,
1048 registry: &crate::ToolRegistry,
1049 name: &str,
1050 ctx: &mut Context,
1051 args: Args,
1052 ) -> Result<Output> {
1053 let tool = registry
1054 .get(name)
1055 .ok_or_else(|| CoreError::NotFound(format!("tool '{name}' not registered")))?;
1056 self.dispatch(tool.as_ref(), ctx, args).await
1057 }
1058
1059 #[must_use]
1061 pub fn rate_limiter(&self) -> &RateLimiter {
1062 &self.rate_limiter
1063 }
1064
1065 #[must_use]
1067 pub fn circuit_breakers(&self) -> &CircuitBreakerRegistry {
1068 &self.circuit_breakers
1069 }
1070
1071 #[must_use]
1073 pub fn dharma_gate(&self) -> &DharmaGate {
1074 &self.dharma_gate
1075 }
1076
1077 #[must_use]
1079 pub fn karma_ledger(&self) -> Option<&KarmaLedger> {
1080 self.karma_ledger.as_deref()
1081 }
1082}
1083
1084impl Default for DispatchPipeline {
1085 fn default() -> Self {
1086 Self::with_defaults()
1087 }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use wm_core::{BrainWave, EffectRow, Gana, Sandbox, ToolStats};
1094 use wm_governance::{ResourceRulesConfig, WriteAuditJournal};
1095
1096 struct TestTool {
1097 name: String,
1098 effects: EffectRow,
1099 stats: ToolStats,
1100 should_fail: bool,
1101 error: Option<fn() -> CoreError>,
1103 output: Option<Output>,
1104 store: Option<Arc<wm_memory::MemoryStore>>,
1107 }
1108
1109 impl TestTool {
1110 fn new(name: &str, effects: EffectRow) -> Self {
1111 Self {
1112 name: name.to_string(),
1113 effects,
1114 stats: ToolStats::default(),
1115 should_fail: false,
1116 error: None,
1117 output: None,
1118 store: None,
1119 }
1120 }
1121
1122 fn returning_error(name: &str, error: fn() -> CoreError) -> Self {
1123 Self {
1124 name: name.to_string(),
1125 effects: EffectRow::pure(),
1126 stats: ToolStats::default(),
1127 should_fail: false,
1128 error: Some(error),
1129 output: None,
1130 store: None,
1131 }
1132 }
1133
1134 fn with_output(mut self, output: Output) -> Self {
1135 self.output = Some(output);
1136 self
1137 }
1138
1139 fn with_store(mut self, store: Arc<wm_memory::MemoryStore>) -> Self {
1140 self.store = Some(store);
1141 self
1142 }
1143
1144 fn failing(name: &str) -> Self {
1145 Self {
1146 name: name.to_string(),
1147 effects: EffectRow::pure(),
1148 stats: ToolStats::default(),
1149 should_fail: true,
1150 error: None,
1151 output: None,
1152 store: None,
1153 }
1154 }
1155 }
1156
1157 #[async_trait]
1158 impl Tool for TestTool {
1159 fn name(&self) -> &str {
1160 &self.name
1161 }
1162 fn gana(&self) -> Gana {
1163 Gana::Heart
1164 }
1165 fn effects(&self) -> &EffectRow {
1166 &self.effects
1167 }
1168 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1169 if let Some(store) = &self.store {
1170 let mem = wm_memory::Memory::new(
1171 wm_core::Galaxy::Codex,
1172 format!("misdeclared write from {}", self.name),
1173 );
1174 store.put(wm_core::Galaxy::Codex, &mem).ok();
1175 }
1176 if let Some(error) = self.error {
1177 Err(error())
1178 } else if self.should_fail {
1179 Err(CoreError::Tool(self.name.clone()))
1180 } else {
1181 Ok(self
1182 .output
1183 .clone()
1184 .unwrap_or_else(|| serde_json::json!("ok")))
1185 }
1186 }
1187 fn stats(&self) -> &ToolStats {
1188 &self.stats
1189 }
1190 }
1191
1192 #[tokio::test]
1193 async fn pipeline_dispatch_success() {
1194 let pipeline = DispatchPipeline::with_defaults();
1195 let mut ctx = Context::new(BrainWave::Gamma);
1196 let tool = TestTool::new("test_tool", EffectRow::pure());
1197
1198 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1199 assert!(result.is_ok());
1200 }
1201
1202 struct HangingTool {
1203 effects: EffectRow,
1204 stats: ToolStats,
1205 }
1206
1207 impl HangingTool {
1208 fn new() -> Self {
1209 Self {
1210 effects: EffectRow::pure(),
1211 stats: ToolStats::default(),
1212 }
1213 }
1214 }
1215
1216 #[async_trait]
1217 impl Tool for HangingTool {
1218 fn name(&self) -> &str {
1219 "hanging_tool"
1220 }
1221 fn gana(&self) -> Gana {
1222 Gana::Heart
1223 }
1224 fn effects(&self) -> &EffectRow {
1225 &self.effects
1226 }
1227 async fn call(&self, _ctx: &mut Context, _args: Args) -> Result<Output> {
1228 tokio::time::sleep(Duration::from_secs(30)).await;
1229 Ok(serde_json::json!("never reached"))
1230 }
1231 fn stats(&self) -> &ToolStats {
1232 &self.stats
1233 }
1234 }
1235
1236 #[tokio::test]
1237 async fn pipeline_dispatch_timeout_bounds_hung_tool() {
1238 let pipeline = DispatchPipeline::with_defaults()
1239 .with_dispatch_timeout(Some(Duration::from_millis(50)));
1240 let mut ctx = Context::new(BrainWave::Gamma);
1241 let tool = HangingTool::new();
1242
1243 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1244 assert!(result.is_err());
1245 let msg = result.err().unwrap().to_string();
1246 assert!(
1247 msg.contains("timed out"),
1248 "expected timeout error, got: {msg}"
1249 );
1250 }
1251
1252 #[tokio::test]
1253 async fn pipeline_dispatch_with_timeout_allows_fast_tool() {
1254 let pipeline = DispatchPipeline::with_defaults()
1255 .with_dispatch_timeout(Some(Duration::from_millis(500)));
1256 let mut ctx = Context::new(BrainWave::Gamma);
1257 let tool = TestTool::new("fast_tool", EffectRow::pure());
1258
1259 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1260 assert!(result.is_ok());
1261 }
1262
1263 #[tokio::test]
1264 async fn pipeline_dispatch_failure_records_stats() {
1265 let pipeline = DispatchPipeline::with_defaults();
1266 let mut ctx = Context::new(BrainWave::Gamma);
1267 let tool = TestTool::failing("failing_tool");
1268
1269 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1270 assert!(result.is_err());
1271 assert_eq!(
1272 tool.stats()
1273 .call_count
1274 .load(std::sync::atomic::Ordering::Relaxed),
1275 1
1276 );
1277 }
1278
1279 #[tokio::test]
1280 async fn pipeline_blocks_incompatible_brain_wave() {
1281 let pipeline = DispatchPipeline::with_defaults();
1282 let mut ctx = Context::new(BrainWave::Delta);
1283 let tool = TestTool::new("test_tool", EffectRow::pure());
1284
1285 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1286 assert!(result.is_err());
1287 match result {
1288 Err(CoreError::Governance(_)) => {}
1289 other => panic!("Expected Governance error, got {other:?}"),
1290 }
1291 }
1292
1293 #[tokio::test]
1294 async fn pipeline_dharma_blocks_destructive_in_strict_mode() {
1295 let pipeline = DispatchPipeline::with_defaults();
1296 let mut ctx = Context::new(BrainWave::Theta);
1297 let tool = TestTool::new(
1298 "destructive_tool",
1299 EffectRow {
1300 writes: vec![wm_core::Resource::Filesystem],
1301 ..Default::default()
1302 },
1303 );
1304
1305 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1306 assert!(result.is_err());
1307 match result {
1308 Err(CoreError::Governance(_)) => {}
1309 other => panic!("Expected Governance error, got {other:?}"),
1310 }
1311 }
1312
1313 #[tokio::test]
1314 async fn pipeline_dharma_confirm_passes_brain_wave_strict_for_destructive() {
1315 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(Arc::new(
1319 ResourceRules::new(ResourceRulesConfig {
1320 require_human_review: false,
1321 ..Default::default()
1322 }),
1323 ));
1324 let mut ctx = Context::new(BrainWave::Theta);
1325 let tool = TestTool::new(
1326 "destructive_tool",
1327 EffectRow {
1328 writes: vec![wm_core::Resource::Filesystem],
1329 ..Default::default()
1330 },
1331 );
1332 let result = pipeline
1333 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1334 .await;
1335 assert!(
1336 result.is_ok(),
1337 "confirmed destructive dispatch must pass brain-wave strict: {result:?}"
1338 );
1339 }
1340
1341 #[tokio::test]
1342 async fn pipeline_capability_gate_strict_blocks_uncredentialed() {
1343 let pipeline =
1344 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1345 let mut ctx = Context::new(BrainWave::Gamma);
1346 let tool = TestTool::new(
1347 "capability_tool",
1348 EffectRow {
1349 invokes: vec![wm_core::Capability::MemoryWrite],
1350 ..Default::default()
1351 },
1352 );
1353
1354 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1355 match result {
1356 Err(CoreError::Governance(msg)) => {
1357 assert!(msg.contains("capability gate"), "{msg}");
1358 assert!(msg.contains("memory:write"), "{msg}");
1359 }
1360 other => panic!("Expected capability refusal, got {other:?}"),
1361 }
1362 }
1363
1364 #[tokio::test]
1365 async fn pipeline_capability_gate_strict_allows_valid_token() {
1366 let pipeline =
1367 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Strict);
1368 let mut ctx = Context::new(BrainWave::Gamma);
1369 let tool = TestTool::new(
1370 "capability_tool_ok",
1371 EffectRow {
1372 invokes: vec![wm_core::Capability::MemoryWrite],
1373 ..Default::default()
1374 },
1375 );
1376
1377 let mut issuer = wm_governance::engagement_tokens::EngagementIssuer::with_keypair(
1378 wm_governance::network_profile::AgentKeypair::from_seed([7u8; 32]),
1379 );
1380 let issuer_key = issuer.signer_public_key_hex();
1381 let token = issuer.issue(
1382 "tester",
1383 wm_governance::engagement_tokens::EngagementScope::Poc,
1384 "rules-hash",
1385 Some(3600),
1386 );
1387 let args = serde_json::json!({
1388 "_engagement": { "token": token, "issuer_public_key": issuer_key }
1389 });
1390
1391 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
1392 assert!(result.is_ok(), "valid Poc token should pass: {result:?}");
1393 }
1394
1395 #[tokio::test]
1396 async fn pipeline_capability_gate_advisory_allows_uncredentialed() {
1397 let pipeline =
1398 DispatchPipeline::with_defaults().with_capability_mode(CapabilityGateMode::Advisory);
1399 let mut ctx = Context::new(BrainWave::Gamma);
1400 let tool = TestTool::new(
1401 "capability_tool_advisory",
1402 EffectRow {
1403 invokes: vec![wm_core::Capability::MemoryWrite],
1404 ..Default::default()
1405 },
1406 );
1407
1408 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1409 assert!(result.is_ok(), "advisory mode must not block: {result:?}");
1410 }
1411
1412 #[tokio::test]
1413 async fn pipeline_rate_limit_blocks_excess() {
1414 let rate_limiter = Arc::new(RateLimiter::new(1000, 2, 0));
1415 let pipeline = DispatchPipeline::new(
1416 rate_limiter,
1417 Arc::new(CircuitBreakerRegistry::default()),
1418 Arc::new(DharmaGate::default()),
1419 None,
1420 );
1421
1422 let mut ctx = Context::new(BrainWave::Gamma);
1423 let tool = TestTool::new("limited_tool", EffectRow::pure());
1424
1425 assert!(
1426 pipeline
1427 .dispatch(&tool, &mut ctx, Args::default())
1428 .await
1429 .is_ok()
1430 );
1431 assert!(
1432 pipeline
1433 .dispatch(&tool, &mut ctx, Args::default())
1434 .await
1435 .is_ok()
1436 );
1437 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1438 assert!(result.is_err());
1439 match result {
1440 Err(CoreError::RateLimited(_)) => {}
1441 other => panic!("Expected RateLimited error, got {other:?}"),
1442 }
1443 }
1444
1445 #[tokio::test]
1446 async fn pipeline_circuit_breaker_opens_on_repeated_failures() {
1447 let breakers = Arc::new(CircuitBreakerRegistry::new(
1448 crate::circuit_breaker::BreakerConfig {
1449 failure_threshold: 3,
1450 window: std::time::Duration::from_secs(10),
1451 cooldown: std::time::Duration::from_secs(30),
1452 },
1453 ));
1454 let pipeline = DispatchPipeline::new(
1455 Arc::new(RateLimiter::new(10000, 100, 100)),
1456 breakers.clone(),
1457 Arc::new(DharmaGate::default()),
1458 None,
1459 );
1460
1461 let mut ctx = Context::new(BrainWave::Gamma);
1462 let tool = TestTool::failing("flaky_tool");
1463
1464 for _ in 0..3 {
1465 let _ = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1466 }
1467
1468 assert_eq!(
1469 breakers.state("flaky_tool"),
1470 crate::circuit_breaker::BreakerState::Open
1471 );
1472
1473 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1474 assert!(result.is_err());
1475 match result {
1476 Err(CoreError::CircuitBreaker(_)) => {}
1477 other => panic!("Expected CircuitBreaker error, got {other:?}"),
1478 }
1479 }
1480
1481 #[tokio::test]
1484 async fn client_validation_errors_do_not_trip_the_breaker() {
1485 let breakers = Arc::new(CircuitBreakerRegistry::new(
1486 crate::circuit_breaker::BreakerConfig {
1487 failure_threshold: 3,
1488 window: std::time::Duration::from_secs(10),
1489 cooldown: std::time::Duration::from_secs(30),
1490 },
1491 ));
1492 let pipeline = DispatchPipeline::new(
1493 Arc::new(RateLimiter::new(10000, 100, 100)),
1494 breakers.clone(),
1495 Arc::new(DharmaGate::default()),
1496 None,
1497 );
1498 let mut ctx = Context::new(BrainWave::Gamma);
1499
1500 let bad = TestTool::returning_error("validated_tool", || {
1503 CoreError::InvalidArgs("unknown galaxy".into())
1504 });
1505 for _ in 0..5 {
1506 let err = pipeline
1507 .dispatch(&bad, &mut ctx, Args::default())
1508 .await
1509 .unwrap_err();
1510 assert!(matches!(err, CoreError::InvalidArgs(_)));
1511 }
1512 assert_eq!(
1513 breakers.state("validated_tool"),
1514 crate::circuit_breaker::BreakerState::Closed,
1515 "caller errors must not open the breaker"
1516 );
1517
1518 let governed = TestTool::returning_error("validated_tool", || {
1520 CoreError::Governance("budget exceeded for writes".into())
1521 });
1522 for _ in 0..5 {
1523 let _ = pipeline
1524 .dispatch(&governed, &mut ctx, Args::default())
1525 .await;
1526 }
1527 assert_eq!(
1528 breakers.state("validated_tool"),
1529 crate::circuit_breaker::BreakerState::Closed,
1530 "governance refusals must not open the breaker"
1531 );
1532
1533 let good = TestTool::new("validated_tool", EffectRow::pure());
1535 pipeline
1536 .dispatch(&good, &mut ctx, Args::default())
1537 .await
1538 .expect("valid call after caller errors");
1539 }
1540
1541 #[tokio::test]
1542 async fn rate_limit_error_names_its_governor() {
1543 let pipeline = DispatchPipeline::new(
1544 Arc::new(RateLimiter::new(1000, 1, 0)),
1545 Arc::new(CircuitBreakerRegistry::default()),
1546 Arc::new(DharmaGate::default()),
1547 None,
1548 );
1549 let mut ctx = Context::new(BrainWave::Gamma);
1550 let tool = TestTool::new("bursty_tool", EffectRow::pure());
1551 pipeline
1552 .dispatch(&tool, &mut ctx, Args::default())
1553 .await
1554 .unwrap();
1555 let err = pipeline
1556 .dispatch(&tool, &mut ctx, Args::default())
1557 .await
1558 .unwrap_err();
1559 let text = err.to_string();
1560 assert!(
1561 text.contains("request rate limit") && text.contains("retry after"),
1562 "rate limit must name its category and retry hint: {text}"
1563 );
1564 }
1565
1566 #[tokio::test]
1567 async fn pipeline_karma_ledger_records() {
1568 let tmp = tempfile::tempdir().unwrap();
1569 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1570 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1571
1572 let pipeline = DispatchPipeline::new(
1573 Arc::new(RateLimiter::default()),
1574 Arc::new(CircuitBreakerRegistry::default()),
1575 Arc::new(DharmaGate::default()),
1576 Some(ledger.clone()),
1577 );
1578
1579 let mut ctx = Context::new(BrainWave::Gamma);
1580 let tool = TestTool::new("karma_test_tool", EffectRow::pure());
1581
1582 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1583 assert!(result.is_ok());
1584 assert_eq!(ledger.next_id(), 1);
1585 assert_eq!(ctx.karma_debt, 0.0);
1586 }
1587
1588 #[tokio::test]
1589 async fn pipeline_karma_debt_updates_context() {
1590 let tmp = tempfile::tempdir().unwrap();
1591 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1592 let ledger = Arc::new(KarmaLedger::new(store).unwrap());
1593
1594 let pipeline = DispatchPipeline::new(
1595 Arc::new(RateLimiter::default()),
1596 Arc::new(CircuitBreakerRegistry::default()),
1597 Arc::new(DharmaGate::default()),
1598 Some(ledger),
1599 );
1600
1601 let mut ctx = Context::new(BrainWave::Gamma);
1602 let tool = TestTool::new(
1603 "wasteful_tool",
1604 EffectRow {
1605 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1606 ..Default::default()
1607 },
1608 );
1609
1610 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1611 assert!(result.is_ok());
1612 assert!(
1613 (ctx.karma_debt - 0.2).abs() < 0.001,
1614 "Context karma_debt should be 0.2, got {}",
1615 ctx.karma_debt
1616 );
1617 }
1618
1619 #[tokio::test]
1620 async fn pipeline_karma_batched_e2e() {
1621 let tmp = tempfile::tempdir().unwrap();
1624 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
1625 let ledger = Arc::new(KarmaLedger::with_flush_threshold(store.clone(), 100).unwrap());
1626
1627 let pipeline = DispatchPipeline::new(
1628 Arc::new(RateLimiter::default()),
1629 Arc::new(CircuitBreakerRegistry::default()),
1630 Arc::new(DharmaGate::default()),
1631 Some(ledger.clone()),
1632 );
1633
1634 let mut ctx = Context::new(BrainWave::Gamma);
1635
1636 let honest_tool = TestTool::new("honest_tool", EffectRow::pure());
1638 let wasteful_tool = TestTool::new(
1639 "wasteful_tool",
1640 EffectRow {
1641 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1642 ..Default::default()
1643 },
1644 );
1645
1646 for _ in 0..10 {
1647 let result = pipeline
1648 .dispatch(&honest_tool, &mut ctx, Args::default())
1649 .await;
1650 assert!(result.is_ok());
1651 }
1652 for _ in 0..10 {
1653 let result = pipeline
1654 .dispatch(&wasteful_tool, &mut ctx, Args::default())
1655 .await;
1656 assert!(result.is_ok());
1657 }
1658
1659 assert_eq!(ledger.next_id(), 20);
1661 assert_eq!(
1662 ledger.pending_count(),
1663 20,
1664 "All 20 entries should be pending before flush"
1665 );
1666
1667 let debt = ledger.total_debt();
1669 assert!(
1670 (debt - 2.0).abs() < 0.001,
1671 "Total debt should be 2.0 (10 x 0.2), got {debt}"
1672 );
1673
1674 ledger.flush().unwrap();
1676 assert_eq!(ledger.pending_count(), 0);
1677
1678 let result = ledger.verify_integrity().unwrap();
1680 assert!(
1681 result.valid,
1682 "Chain should be valid after batched flush: {:?}",
1683 result.violation
1684 );
1685 assert_eq!(result.entries_verified, 20);
1686
1687 let ledger2 = KarmaLedger::new(store).unwrap();
1689 assert_eq!(
1690 ledger2.next_id(),
1691 20,
1692 "Next ID should persist across instances"
1693 );
1694 let entries = ledger2.scan_entries().unwrap();
1695 assert_eq!(
1696 entries.len(),
1697 20,
1698 "All 20 entries should be persisted in LMDB"
1699 );
1700
1701 let debt2 = ledger2.total_debt();
1703 assert!(
1704 (debt2 - 2.0).abs() < 0.001,
1705 "Total debt should persist as 2.0, got {debt2}"
1706 );
1707
1708 let result2 = ledger2.verify_integrity().unwrap();
1710 assert!(result2.valid, "Chain should be valid on reloaded ledger");
1711 assert_eq!(result2.entries_verified, 20);
1712 }
1713
1714 #[tokio::test]
1715 async fn pipeline_coherence_gate_blocks_writes() {
1716 let pipeline = DispatchPipeline::with_defaults();
1717 let mut ctx = Context::new(BrainWave::Gamma);
1718 ctx.citta_coherence = 0.1; let tool = TestTool::new(
1720 "write_tool",
1721 EffectRow {
1722 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1723 ..Default::default()
1724 },
1725 );
1726
1727 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1728 assert!(result.is_err());
1729 match result {
1730 Err(CoreError::Governance(msg)) => {
1731 assert!(msg.contains("coherence"));
1732 }
1733 other => panic!("Expected Governance error, got {other:?}"),
1734 }
1735 }
1736
1737 #[tokio::test]
1738 async fn pipeline_coherence_gate_allows_reads() {
1739 let pipeline = DispatchPipeline::with_defaults();
1740 let mut ctx = Context::new(BrainWave::Gamma);
1741 ctx.citta_coherence = 0.1; let tool = TestTool::new("read_tool", EffectRow::pure());
1743
1744 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1745 assert!(result.is_ok());
1746 }
1747
1748 #[tokio::test]
1749 async fn pipeline_coherence_gate_allows_writes_when_coherent() {
1750 let pipeline = DispatchPipeline::with_defaults();
1751 let mut ctx = Context::new(BrainWave::Gamma);
1752 ctx.citta_coherence = 0.5; let tool = TestTool::new(
1754 "write_tool",
1755 EffectRow {
1756 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1757 ..Default::default()
1758 },
1759 );
1760
1761 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1762 assert!(result.is_ok());
1763 }
1764
1765 #[tokio::test]
1766 async fn pipeline_low_confidence_blocks_writes() {
1767 let pipeline = DispatchPipeline::with_defaults();
1768 let mut ctx = Context::new(BrainWave::Gamma);
1769 ctx.self_model_confidence = 0.3; let tool = TestTool::new(
1771 "write_tool",
1772 EffectRow {
1773 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1774 ..Default::default()
1775 },
1776 );
1777
1778 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1779 assert!(result.is_err());
1780 match result {
1781 Err(CoreError::Governance(msg)) => {
1782 assert!(msg.contains("confidence"));
1783 assert!(msg.contains("conservative"));
1784 }
1785 other => panic!("Expected Governance error, got {other:?}"),
1786 }
1787 }
1788
1789 #[tokio::test]
1790 async fn pipeline_low_confidence_allows_reads() {
1791 let pipeline = DispatchPipeline::with_defaults();
1792 let mut ctx = Context::new(BrainWave::Gamma);
1793 ctx.self_model_confidence = 0.3; let tool = TestTool::new("read_tool", EffectRow::pure());
1795
1796 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1797 assert!(result.is_ok());
1798 }
1799
1800 #[tokio::test]
1801 async fn pipeline_high_confidence_allows_writes() {
1802 let pipeline = DispatchPipeline::with_defaults();
1803 let mut ctx = Context::new(BrainWave::Gamma);
1804 ctx.self_model_confidence = 0.8; let tool = TestTool::new(
1806 "write_tool",
1807 EffectRow {
1808 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1809 ..Default::default()
1810 },
1811 );
1812
1813 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1814 assert!(result.is_ok());
1815 }
1816
1817 #[tokio::test]
1818 async fn pipeline_high_caution_warns_on_writes() {
1819 let pipeline = DispatchPipeline::with_defaults();
1820 let mut ctx = Context::new(BrainWave::Gamma);
1821 ctx.drive_caution = 0.9; let tool = TestTool::new(
1823 "write_tool",
1824 EffectRow {
1825 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1826 ..Default::default()
1827 },
1828 );
1829
1830 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1832 assert!(result.is_ok());
1833 }
1834
1835 #[tokio::test]
1836 async fn pipeline_low_energy_warns_on_writes() {
1837 let pipeline = DispatchPipeline::with_defaults();
1838 let mut ctx = Context::new(BrainWave::Gamma);
1839 ctx.drive_energy = 0.1; let tool = TestTool::new(
1841 "write_tool",
1842 EffectRow {
1843 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1844 ..Default::default()
1845 },
1846 );
1847
1848 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1850 assert!(result.is_ok());
1851 }
1852
1853 #[tokio::test]
1854 async fn pipeline_drive_gates_dont_affect_reads() {
1855 let pipeline = DispatchPipeline::with_defaults();
1856 let mut ctx = Context::new(BrainWave::Gamma);
1857 ctx.drive_caution = 0.95;
1858 ctx.drive_energy = 0.05;
1859 let tool = TestTool::new("read_tool", EffectRow::pure());
1860
1861 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1862 assert!(result.is_ok());
1863 }
1864
1865 #[tokio::test]
1866 async fn pipeline_destructive_blocked_without_confirm() {
1867 let pipeline = DispatchPipeline::with_defaults();
1868 let mut ctx = Context::new(BrainWave::Gamma);
1869 let tool = TestTool::new(
1870 "destructive_tool",
1871 EffectRow {
1872 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1873 destructive: true,
1874 ..Default::default()
1875 },
1876 );
1877
1878 let result = pipeline
1879 .dispatch(&tool, &mut ctx, serde_json::json!({}))
1880 .await;
1881 assert!(result.is_err());
1882 match result {
1883 Err(CoreError::Governance(msg)) => {
1884 assert!(msg.contains("destructive"));
1885 assert!(msg.contains("confirm"));
1886 }
1887 other => panic!("Expected Governance error, got {other:?}"),
1888 }
1889 }
1890
1891 #[tokio::test]
1892 async fn pipeline_destructive_allowed_with_confirm() {
1893 let pipeline = DispatchPipeline::with_defaults();
1894 let mut ctx = Context::new(BrainWave::Gamma);
1895 let tool = TestTool::new(
1896 "destructive_tool",
1897 EffectRow {
1898 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1899 destructive: true,
1900 ..Default::default()
1901 },
1902 );
1903
1904 let result = pipeline
1905 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
1906 .await;
1907 assert!(result.is_ok());
1908 }
1909
1910 #[tokio::test]
1911 async fn pipeline_destructive_blocked_with_false_confirm() {
1912 let pipeline = DispatchPipeline::with_defaults();
1913 let mut ctx = Context::new(BrainWave::Gamma);
1914 let tool = TestTool::new(
1915 "destructive_tool",
1916 EffectRow {
1917 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1918 destructive: true,
1919 ..Default::default()
1920 },
1921 );
1922
1923 let result = pipeline
1924 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": false}))
1925 .await;
1926 assert!(result.is_err());
1927 }
1928
1929 #[tokio::test]
1930 async fn pipeline_compartment_no_restriction_allows_all() {
1931 let pipeline = DispatchPipeline::with_defaults();
1932 let mut ctx = Context::new(BrainWave::Gamma);
1933 let tool = TestTool::new(
1935 "write_tool",
1936 EffectRow {
1937 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1938 ..Default::default()
1939 },
1940 );
1941
1942 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1943 assert!(result.is_ok());
1944 }
1945
1946 #[tokio::test]
1947 async fn pipeline_compartment_sandbox_blocks_write_to_codex() {
1948 let pipeline = DispatchPipeline::with_defaults();
1949 let mut ctx = Context::new(BrainWave::Gamma);
1950 ctx.compartment = Some("sandbox".into());
1951 let tool = TestTool::new(
1952 "write_tool",
1953 EffectRow {
1954 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1955 ..Default::default()
1956 },
1957 );
1958
1959 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1960 assert!(result.is_err());
1961 match result {
1962 Err(CoreError::Governance(msg)) => {
1963 assert!(msg.contains("sandbox"));
1964 assert!(msg.contains("codex"));
1965 }
1966 other => panic!("Expected Governance error, got {other:?}"),
1967 }
1968 }
1969
1970 #[tokio::test]
1971 async fn pipeline_asserted_user_id_confers_no_authority() {
1972 let pipeline = DispatchPipeline::with_defaults();
1977 let mut ctx = Context::new(BrainWave::Gamma);
1978 ctx.compartment = Some("sandbox".into());
1979 ctx.user_id = Some("ceo".into());
1980 let tool = TestTool::new(
1981 "write_tool",
1982 EffectRow {
1983 writes: vec![wm_core::Resource::Galaxy("codex".into())],
1984 ..Default::default()
1985 },
1986 );
1987
1988 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
1989 assert!(result.is_err());
1990 match result {
1991 Err(CoreError::Governance(msg)) => {
1992 assert!(msg.contains("sandbox"));
1993 assert!(msg.contains("codex"));
1994 }
1995 other => panic!("Expected Governance error, got {other:?}"),
1996 }
1997 }
1998
1999 #[tokio::test]
2000 async fn pipeline_routes_store_scoped_tools_through_executor() {
2001 use crate::sandbox_exec::ScopedSandboxExecutor;
2004 use std::sync::atomic::{AtomicU64, Ordering};
2005 let calls = Arc::new(AtomicU64::new(0));
2006 let counter = Arc::clone(&calls);
2007 let executor = Arc::new(ScopedSandboxExecutor::new(move || {
2008 counter.fetch_add(1, Ordering::SeqCst);
2009 Ok(())
2010 }));
2011 let pipeline =
2012 DispatchPipeline::with_defaults().with_sandbox_executor(Some(Arc::clone(&executor)));
2013 let mut ctx = Context::new(BrainWave::Gamma);
2014
2015 let scoped = TestTool::new(
2016 "scoped_tool",
2017 EffectRow {
2018 sandbox: Sandbox::StoreScoped,
2019 ..Default::default()
2020 },
2021 );
2022 assert!(
2023 pipeline
2024 .dispatch(&scoped, &mut ctx, Args::default())
2025 .await
2026 .is_ok()
2027 );
2028 assert_eq!(calls.load(Ordering::SeqCst), 1, "scoped tool must confine");
2029
2030 let plain = TestTool::new("plain_tool", EffectRow::pure());
2031 assert!(
2032 pipeline
2033 .dispatch(&plain, &mut ctx, Args::default())
2034 .await
2035 .is_ok()
2036 );
2037 assert_eq!(
2038 calls.load(Ordering::SeqCst),
2039 1,
2040 "plain tools must not ride the sandbox path"
2041 );
2042 assert_eq!(executor.stats(), (1, 0, 0));
2043
2044 let bare = DispatchPipeline::with_defaults();
2046 let scoped2 = TestTool::new(
2047 "scoped_tool",
2048 EffectRow {
2049 sandbox: Sandbox::StoreScoped,
2050 ..Default::default()
2051 },
2052 );
2053 assert!(
2054 bare.dispatch(&scoped2, &mut ctx, Args::default())
2055 .await
2056 .is_ok()
2057 );
2058 }
2059
2060 #[tokio::test]
2061 async fn pipeline_injects_subprocess_policy_and_discloses() {
2062 use crate::subprocess_sandbox::SubprocessSandbox;
2066 use std::path::PathBuf;
2067 use wm_core::sandbox::RunnerSource;
2068 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2069 wm_core::sandbox::RunnerInfo {
2070 path: PathBuf::from("/opt/mandala-sandbox"),
2071 source: RunnerSource::Env,
2072 },
2073 )));
2074 let pipeline =
2075 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2076 let mut ctx = Context::new(BrainWave::Gamma);
2077 let tool = TestTool::new(
2078 "spawn_tool",
2079 EffectRow {
2080 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2081 spawns: true,
2082 sandbox: Sandbox::Subprocess,
2083 ..Default::default()
2084 },
2085 )
2086 .with_output(serde_json::json!({"ok": true}));
2087
2088 let out = pipeline
2089 .dispatch(&tool, &mut ctx, Args::default())
2090 .await
2091 .expect("declared spawn tool dispatches");
2092 assert!(ctx.spawn.is_active(), "policy must ride the context");
2093 assert!(ctx.spawn.allow_net(), "network read grants the runner net");
2094 assert_eq!(out["sandbox"]["runner"], "/opt/mandala-sandbox");
2095 assert_eq!(out["sandbox"]["net"], true);
2096 assert_eq!(
2097 out["sandbox"]["envelope"],
2098 wm_core::sandbox::ENVELOPE_SCHEMA
2099 );
2100 assert_eq!(sandbox.status()["dispatches"], 1);
2101 assert_eq!(sandbox.status()["degraded"], 0);
2102 }
2103
2104 #[tokio::test]
2105 async fn pipeline_degrades_loudly_when_runner_missing() {
2106 use crate::subprocess_sandbox::SubprocessSandbox;
2109 let sandbox = Arc::new(SubprocessSandbox::with_runner(None));
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 = TestTool::new(
2114 "spawn_tool",
2115 EffectRow {
2116 reads: vec![wm_core::Resource::Process],
2117 spawns: true,
2118 sandbox: Sandbox::Subprocess,
2119 ..Default::default()
2120 },
2121 )
2122 .with_output(serde_json::json!({"ok": true}));
2123
2124 let out = pipeline
2125 .dispatch(&tool, &mut ctx, Args::default())
2126 .await
2127 .expect("degrade keeps availability up");
2128 assert!(!ctx.spawn.is_active());
2129 assert!(
2130 out.get("sandbox").is_none(),
2131 "no runner means no confinement claim"
2132 );
2133 assert_eq!(sandbox.status()["dispatches"], 1);
2134 assert_eq!(sandbox.status()["degraded"], 1);
2135 }
2136
2137 #[tokio::test]
2138 async fn pipeline_surfaces_unmigrated_spawn_tools() {
2139 use crate::subprocess_sandbox::SubprocessSandbox;
2143 use std::path::PathBuf;
2144 use wm_core::sandbox::RunnerSource;
2145 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(
2146 wm_core::sandbox::RunnerInfo {
2147 path: PathBuf::from("/opt/mandala-sandbox"),
2148 source: RunnerSource::Env,
2149 },
2150 )));
2151 let pipeline =
2152 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2153 let mut ctx = Context::new(BrainWave::Gamma);
2154 let tool = TestTool::new(
2155 "legacy_git_tool",
2156 EffectRow {
2157 reads: vec![wm_core::Resource::Process],
2158 spawns: true,
2159 ..Default::default()
2160 },
2161 );
2162
2163 assert!(
2164 pipeline
2165 .dispatch(&tool, &mut ctx, Args::default())
2166 .await
2167 .is_ok()
2168 );
2169 assert!(!ctx.spawn.is_active());
2170 assert_eq!(sandbox.status()["unconfined_spawns"], 1);
2171 assert_eq!(sandbox.status()["dispatches"], 0);
2172 }
2173
2174 #[cfg(unix)]
2175 #[tokio::test]
2176 async fn declared_spawn_executes_through_the_runner_envelope() {
2177 use crate::subprocess_sandbox::SubprocessSandbox;
2181 use std::os::unix::fs::PermissionsExt;
2182 use wm_core::sandbox::{RunnerInfo, RunnerSource};
2183
2184 let dir = tempfile::tempdir().expect("tempdir");
2185 let marker = dir.path().join("envelope.json");
2186 let runner = dir.path().join("fake-runner");
2187 std::fs::write(
2188 &runner,
2189 format!(
2190 "#!/bin/sh\nprintf '%s' \"$2\" > '{}'\nexit 0\n",
2191 marker.display()
2192 ),
2193 )
2194 .expect("write fake runner");
2195 std::fs::set_permissions(&runner, std::fs::Permissions::from_mode(0o755))
2196 .expect("chmod fake runner");
2197
2198 struct SpawnProbeTool {
2199 effects: EffectRow,
2200 stats: ToolStats,
2201 }
2202 #[async_trait]
2203 impl Tool for SpawnProbeTool {
2204 fn name(&self) -> &str {
2205 "spawn_probe"
2206 }
2207 fn gana(&self) -> Gana {
2208 Gana::Heart
2209 }
2210 fn effects(&self) -> &EffectRow {
2211 &self.effects
2212 }
2213 async fn call(&self, ctx: &mut Context, _args: Args) -> Result<Output> {
2214 let out = ctx
2215 .spawn
2216 .command("printf", &["%s", "hi"])
2217 .output()
2218 .map_err(|e| CoreError::Tool(format!("spawn failed: {e}")))?;
2219 if !out.status.success() {
2220 return Err(CoreError::Tool("wrapped command failed".into()));
2221 }
2222 Ok(serde_json::json!({"ok": true}))
2223 }
2224 fn stats(&self) -> &ToolStats {
2225 &self.stats
2226 }
2227 }
2228
2229 let sandbox = Arc::new(SubprocessSandbox::with_runner(Some(RunnerInfo {
2230 path: runner,
2231 source: RunnerSource::Env,
2232 })));
2233 let pipeline =
2234 DispatchPipeline::with_defaults().with_subprocess_sandbox(Some(Arc::clone(&sandbox)));
2235 let mut ctx = Context::new(BrainWave::Gamma);
2236 let tool = SpawnProbeTool {
2237 effects: EffectRow {
2238 reads: vec![wm_core::Resource::Network, wm_core::Resource::Process],
2239 spawns: true,
2240 sandbox: Sandbox::Subprocess,
2241 ..Default::default()
2242 },
2243 stats: ToolStats::default(),
2244 };
2245 let out = pipeline
2246 .dispatch(&tool, &mut ctx, Args::default())
2247 .await
2248 .expect("wrapped spawn succeeds");
2249 assert_eq!(out["ok"], true);
2250 assert_eq!(out["sandbox"]["net"], true);
2251
2252 let captured = std::fs::read_to_string(&marker).expect("runner captured the envelope");
2253 let envelope: serde_json::Value = serde_json::from_str(&captured).expect("envelope JSON");
2254 assert_eq!(envelope["schema"], wm_core::sandbox::ENVELOPE_SCHEMA);
2255 assert_eq!(envelope["program"], "printf");
2256 assert_eq!(envelope["args"], serde_json::json!(["%s", "hi"]));
2257 assert_eq!(envelope["net"], true);
2258 }
2259
2260 #[tokio::test]
2261 async fn pipeline_secret_scan_warns_without_blocking() {
2262 use crate::secret_scan::SecretSampler;
2266 let sampler = Arc::new(SecretSampler::new(1));
2267 let pipeline =
2268 DispatchPipeline::with_defaults().with_secret_scan_option(Some(Arc::clone(&sampler)));
2269 let mut ctx = Context::new(BrainWave::Gamma);
2270 let tool = TestTool::new("key_tool", EffectRow::pure())
2271 .with_output(serde_json::json!({"data": "key=AKIAIOSFODNN7EXAMPLE"}));
2272 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2273 assert!(result.is_ok(), "warn-only scan must never block");
2274 assert_eq!(sampler.stats(), (1, 1, 1));
2275
2276 let clean = TestTool::new("clean_tool", EffectRow::pure())
2278 .with_output(serde_json::json!({"results": []}));
2279 assert!(
2280 pipeline
2281 .dispatch(&clean, &mut ctx, Args::default())
2282 .await
2283 .is_ok()
2284 );
2285 assert_eq!(sampler.stats(), (2, 2, 1));
2286 }
2287
2288 #[tokio::test]
2289 async fn pipeline_compartment_sandbox_blocks_read_from_karma() {
2290 let pipeline = DispatchPipeline::with_defaults();
2291 let mut ctx = Context::new(BrainWave::Gamma);
2292 ctx.compartment = Some("sandbox".into());
2293 let tool = TestTool::new(
2294 "read_tool",
2295 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2296 );
2297
2298 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2299 assert!(result.is_err());
2300 match result {
2301 Err(CoreError::Governance(msg)) => {
2302 assert!(msg.contains("sandbox"));
2303 assert!(msg.contains("karma"));
2304 }
2305 other => panic!("Expected Governance error, got {other:?}"),
2306 }
2307 }
2308
2309 #[tokio::test]
2310 async fn pipeline_compartment_sandbox_allows_write_to_tutorial() {
2311 let pipeline = DispatchPipeline::with_defaults();
2312 let mut ctx = Context::new(BrainWave::Gamma);
2313 ctx.compartment = Some("sandbox".into());
2314 let tool = TestTool::new(
2315 "write_tool",
2316 EffectRow {
2317 writes: vec![wm_core::Resource::Galaxy("tutorial".into())],
2318 ..Default::default()
2319 },
2320 );
2321
2322 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2323 assert!(result.is_ok());
2324 }
2325
2326 #[tokio::test]
2327 async fn pipeline_compartment_sandbox_allows_read_from_research() {
2328 let pipeline = DispatchPipeline::with_defaults();
2329 let mut ctx = Context::new(BrainWave::Gamma);
2330 ctx.compartment = Some("sandbox".into());
2331 let tool = TestTool::new(
2332 "read_tool",
2333 EffectRow::read_only(vec![wm_core::Resource::Galaxy("research".into())]),
2334 );
2335
2336 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2337 assert!(result.is_ok());
2338 }
2339
2340 #[tokio::test]
2341 async fn pipeline_compartment_production_blocks_read_from_karma() {
2342 let pipeline = DispatchPipeline::with_defaults();
2343 let mut ctx = Context::new(BrainWave::Gamma);
2344 ctx.compartment = Some("production".into());
2345 let tool = TestTool::new(
2346 "read_tool",
2347 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2348 );
2349
2350 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2351 assert!(result.is_err());
2352 match result {
2353 Err(CoreError::Governance(msg)) => {
2354 assert!(msg.contains("production"));
2355 assert!(msg.contains("karma"));
2356 }
2357 other => panic!("Expected Governance error, got {other:?}"),
2358 }
2359 }
2360
2361 #[tokio::test]
2362 async fn pipeline_compartment_production_allows_write_to_codex() {
2363 let pipeline = DispatchPipeline::with_defaults();
2364 let mut ctx = Context::new(BrainWave::Gamma);
2365 ctx.compartment = Some("production".into());
2366 let tool = TestTool::new(
2367 "write_tool",
2368 EffectRow {
2369 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2370 ..Default::default()
2371 },
2372 );
2373
2374 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2375 assert!(result.is_ok());
2376 }
2377
2378 #[tokio::test]
2379 async fn pipeline_compartment_secure_allows_write_to_codex() {
2380 let pipeline = DispatchPipeline::with_defaults();
2381 let mut ctx = Context::new(BrainWave::Gamma);
2382 ctx.compartment = Some("secure".into());
2383 let tool = TestTool::new(
2384 "write_tool",
2385 EffectRow {
2386 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2387 ..Default::default()
2388 },
2389 );
2390
2391 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2392 assert!(result.is_ok());
2393 }
2394
2395 #[tokio::test]
2396 async fn pipeline_compartment_secure_blocks_read_from_karma() {
2397 let pipeline = DispatchPipeline::with_defaults();
2398 let mut ctx = Context::new(BrainWave::Gamma);
2399 ctx.compartment = Some("secure".into());
2400 let tool = TestTool::new(
2401 "read_tool",
2402 EffectRow::read_only(vec![wm_core::Resource::Galaxy("karma".into())]),
2403 );
2404
2405 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2406 assert!(result.is_err());
2407 match result {
2408 Err(CoreError::Governance(msg)) => {
2409 assert!(msg.contains("secure"));
2410 assert!(msg.contains("karma"));
2411 }
2412 other => panic!("Expected Governance error, got {other:?}"),
2413 }
2414 }
2415
2416 fn rules_with(max_writes: u32, max_repeats: u32) -> Arc<ResourceRules> {
2419 Arc::new(ResourceRules::new(ResourceRulesConfig {
2420 max_writes_per_minute: max_writes,
2421 max_spawns_per_minute: 100,
2422 max_network_per_minute: 100,
2423 novelty_window: 50,
2424 max_repeats,
2425 require_human_review: false,
2426 }))
2427 }
2428
2429 #[tokio::test]
2430 async fn pipeline_resource_rules_budget_exceeding_write_refused() {
2431 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(2, 1000));
2432 let mut ctx = Context::new(BrainWave::Gamma);
2433 let tool = TestTool::new(
2434 "write_tool",
2435 EffectRow {
2436 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2437 ..Default::default()
2438 },
2439 );
2440
2441 assert!(
2442 pipeline
2443 .dispatch(&tool, &mut ctx, Args::default())
2444 .await
2445 .is_ok(),
2446 "first write within budget"
2447 );
2448 assert!(
2449 pipeline
2450 .dispatch(&tool, &mut ctx, Args::default())
2451 .await
2452 .is_ok(),
2453 "second write within budget"
2454 );
2455 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2456 assert!(result.is_err(), "third write must exceed the budget");
2457 match result {
2458 Err(CoreError::Governance(msg)) => {
2459 assert!(msg.contains("resource rules"), "got: {msg}");
2460 assert!(msg.contains("writes"), "got: {msg}");
2461 }
2462 other => panic!("Expected Governance error, got {other:?}"),
2463 }
2464 }
2465
2466 #[tokio::test]
2467 async fn pipeline_resource_rules_novelty_flag_reaches_response() {
2468 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules_with(1000, 1));
2469 let mut ctx = Context::new(BrainWave::Gamma);
2470 let tool = TestTool::new("read_tool", EffectRow::pure())
2471 .with_output(serde_json::json!({"status": "ok"}));
2472
2473 let first = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2474 assert!(first.is_ok());
2475 assert!(
2476 first.unwrap().get("resource_flags").is_none(),
2477 "first call is novel — no flag"
2478 );
2479
2480 let second = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2481 let output = second.expect("repeated call must still succeed (flag, not block)");
2482 let flags = output
2483 .get("resource_flags")
2484 .and_then(|f| f.as_array())
2485 .expect("novelty flag must reach the response");
2486 assert_eq!(flags.len(), 1);
2487 assert!(flags[0].as_str().unwrap().contains("not novel"));
2488 }
2489
2490 #[tokio::test]
2491 async fn pipeline_resource_rules_blocks_unapproved_autonomous() {
2492 let rules = Arc::new(ResourceRules::default());
2493 rules.set_user_initiated(false);
2494 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2495 let mut ctx = Context::new(BrainWave::Gamma);
2496 let tool = TestTool::new(
2497 "memory.consolidate",
2498 EffectRow {
2499 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2500 ..Default::default()
2501 },
2502 );
2503
2504 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2505 assert!(result.is_err());
2506 match result {
2507 Err(CoreError::Governance(msg)) => {
2508 assert!(msg.contains("human review"), "got: {msg}");
2509 }
2510 other => panic!("Expected Governance error, got {other:?}"),
2511 }
2512 }
2513
2514 #[tokio::test]
2515 async fn pipeline_resource_rules_allows_approved_autonomous() {
2516 let rules = Arc::new(ResourceRules::default());
2517 rules.set_user_initiated(false);
2518 rules.set_human_approved(true);
2519 let pipeline = DispatchPipeline::with_defaults().with_resource_rules(rules);
2520 let mut ctx = Context::new(BrainWave::Gamma);
2521 let tool = TestTool::new(
2522 "memory.consolidate",
2523 EffectRow {
2524 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2525 ..Default::default()
2526 },
2527 );
2528
2529 let result = pipeline
2530 .dispatch(
2531 &tool,
2532 &mut ctx,
2533 serde_json::json!({"purpose": "consolidate codex"}),
2534 )
2535 .await;
2536 assert!(result.is_ok());
2537 }
2538
2539 #[tokio::test]
2540 async fn pipeline_resource_rules_user_initiated_writes_allowed_by_default() {
2541 let pipeline = DispatchPipeline::with_defaults()
2543 .with_resource_rules(Arc::new(ResourceRules::default()));
2544 let mut ctx = Context::new(BrainWave::Gamma);
2545 let tool = TestTool::new(
2546 "write_tool",
2547 EffectRow {
2548 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2549 ..Default::default()
2550 },
2551 );
2552
2553 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2554 assert!(result.is_ok());
2555 }
2556
2557 #[tokio::test]
2560 async fn pipeline_runtime_satya_blocks_citta_write_without_read() {
2561 let pipeline = DispatchPipeline::with_defaults();
2562 let mut ctx = Context::new(BrainWave::Gamma);
2563 let tool = TestTool::new(
2564 "memory.create",
2565 EffectRow {
2566 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2567 ..Default::default()
2568 },
2569 );
2570
2571 let result = pipeline
2572 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2573 .await;
2574 assert!(result.is_err());
2575 match result {
2576 Err(CoreError::Governance(msg)) => {
2577 assert!(msg.contains("VIOLATION_SATYA"), "got: {msg}");
2578 }
2579 other => panic!("Expected Governance error, got {other:?}"),
2580 }
2581 }
2582
2583 #[tokio::test]
2584 async fn pipeline_runtime_satya_allows_citta_write_with_read_evidence() {
2585 let pipeline = DispatchPipeline::with_defaults();
2586 let mut ctx = Context::new(BrainWave::Gamma);
2587 let tool = TestTool::new(
2588 "consolidate_tool",
2589 EffectRow {
2590 reads: vec![wm_core::Resource::Galaxy("citta".into())],
2591 writes: vec![wm_core::Resource::Galaxy("citta".into())],
2592 ..Default::default()
2593 },
2594 );
2595
2596 let result = pipeline
2597 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "citta"}))
2598 .await;
2599 assert!(result.is_ok());
2600 }
2601
2602 #[tokio::test]
2603 async fn pipeline_runtime_satya_allows_non_citta_runtime_galaxy() {
2604 let pipeline = DispatchPipeline::with_defaults();
2605 let mut ctx = Context::new(BrainWave::Gamma);
2606 let tool = TestTool::new(
2607 "memory.create",
2608 EffectRow {
2609 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2610 ..Default::default()
2611 },
2612 );
2613
2614 let result = pipeline
2615 .dispatch(&tool, &mut ctx, serde_json::json!({"galaxy": "research"}))
2616 .await;
2617 assert!(result.is_ok());
2618 }
2619
2620 #[tokio::test]
2623 async fn pipeline_write_audit_detects_misdeclaring_tool() {
2624 let tmp = tempfile::tempdir().unwrap();
2625 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2626 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2627 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2628 let mut ctx = Context::new(BrainWave::Gamma);
2629
2630 let tool = TestTool::new("sneaky_tool", EffectRow::pure()).with_store(store);
2632
2633 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2634 assert!(result.is_ok());
2635
2636 let mis = journal.misdeclarations().unwrap();
2637 assert!(!mis.is_empty(), "misdeclaring tool must be detected");
2638 assert_eq!(mis.last().unwrap().tool, "sneaky_tool");
2639 assert!(mis.last().unwrap().undeclared_mutation());
2640 }
2641
2642 #[tokio::test]
2643 async fn pipeline_write_audit_skips_meta_router() {
2644 let tmp = tempfile::tempdir().unwrap();
2645 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2646 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2647 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2648 let mut ctx = Context::new(BrainWave::Gamma);
2649
2650 let tool = TestTool::new("wm", EffectRow::pure()).with_store(store);
2654 let result = pipeline.dispatch(&tool, &mut ctx, Args::default()).await;
2655 assert!(result.is_ok());
2656
2657 let mis = journal.misdeclarations().unwrap();
2658 assert!(
2659 mis.iter().all(|m| m.tool != "wm"),
2660 "meta router must not appear as a misdeclaration: {mis:?}"
2661 );
2662 }
2663
2664 #[tokio::test]
2665 async fn pipeline_write_audit_records_declared_writes_with_identity() {
2666 let tmp = tempfile::tempdir().unwrap();
2667 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2668 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2669 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2670 let mut ctx = Context::new(BrainWave::Gamma);
2671
2672 let tool = TestTool::new(
2673 "honest_tool",
2674 EffectRow {
2675 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2676 ..Default::default()
2677 },
2678 )
2679 .with_store(store);
2680
2681 let args = serde_json::json!({"id": "abc-123", "content_hash": "hash-xyz"});
2682 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
2683 assert!(result.is_ok());
2684
2685 let entries = journal.scan_entries().unwrap();
2686 assert_eq!(entries.len(), 1);
2687 let entry = &entries[0];
2688 assert!(entry.declared_writes);
2689 assert!(entry.store_write_delta >= 1);
2690 assert_eq!(entry.memory_id.as_deref(), Some("abc-123"));
2691 assert_eq!(entry.content_hash.as_deref(), Some("hash-xyz"));
2692 assert!(journal.misdeclarations().unwrap().is_empty());
2693 }
2694
2695 #[tokio::test]
2696 async fn pipeline_write_audit_captures_actor_identity() {
2697 let tmp = tempfile::tempdir().unwrap();
2700 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2701 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2702 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2703 let mut ctx = Context::new(BrainWave::Gamma);
2704 ctx.session_id = Some(uuid::Uuid::nil());
2705 ctx.user_id = Some("agent-b".to_string());
2706 ctx.compartment = Some("production".to_string());
2707
2708 let tool = TestTool::new(
2709 "honest_tool",
2710 EffectRow {
2711 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2712 ..Default::default()
2713 },
2714 )
2715 .with_store(store);
2716
2717 let result = pipeline
2718 .dispatch(&tool, &mut ctx, serde_json::json!({"id": "abc-123"}))
2719 .await;
2720 assert!(result.is_ok());
2721
2722 let entries = journal.scan_entries().unwrap();
2723 assert_eq!(entries.len(), 1);
2724 let entry = &entries[0];
2725 assert_eq!(
2726 entry.actor_session.as_deref(),
2727 Some(uuid::Uuid::nil().to_string().as_str())
2728 );
2729 assert_eq!(entry.actor_user.as_deref(), Some("agent-b"));
2730 assert_eq!(entry.actor_compartment.as_deref(), Some("production"));
2731 }
2732
2733 #[tokio::test]
2734 async fn pipeline_write_audit_read_dispatch_not_flagged_after_external_writes() {
2735 let tmp = tempfile::tempdir().unwrap();
2740 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2741 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2742 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2743 let mut ctx = Context::new(BrainWave::Gamma);
2744
2745 for i in 0..3 {
2747 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("other session {i}"));
2748 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
2749 }
2750
2751 let read_tool = TestTool::new("memory.search", EffectRow::pure());
2752 let result = pipeline
2753 .dispatch(&read_tool, &mut ctx, Args::default())
2754 .await;
2755 assert!(result.is_ok());
2756
2757 let mis = journal.misdeclarations().unwrap();
2758 assert!(
2759 mis.is_empty(),
2760 "read-only dispatch must not inherit the other session's writes: {mis:?}"
2761 );
2762 let entries = journal.scan_entries().unwrap();
2763 assert_eq!(entries.last().unwrap().store_write_delta, 0);
2764 }
2765
2766 #[tokio::test]
2769 async fn pipeline_firebreak_forbidden_blocks_even_with_confirm() {
2770 let pipeline = DispatchPipeline::with_defaults();
2771 let mut ctx = Context::new(BrainWave::Gamma);
2772 let tool = TestTool::new(
2773 "destructive_tool",
2774 EffectRow {
2775 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2776 destructive: true,
2777 ..Default::default()
2778 },
2779 );
2780
2781 let result = pipeline
2782 .dispatch(
2783 &tool,
2784 &mut ctx,
2785 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
2786 )
2787 .await;
2788 match result {
2789 Err(CoreError::Governance(msg)) => {
2790 assert!(msg.contains("FORBIDDEN"), "got: {msg}");
2791 assert!(msg.contains("never allowed"), "got: {msg}");
2792 }
2793 other => panic!("Expected Governance error, got {other:?}"),
2794 }
2795 }
2796
2797 #[tokio::test]
2798 async fn pipeline_firebreak_scope_law_blocks_unscoped_destructive() {
2799 let pipeline = DispatchPipeline::with_defaults();
2800 let mut ctx = Context::new(BrainWave::Gamma);
2801 let tool = TestTool::new(
2803 "memory.delete",
2804 EffectRow {
2805 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2806 destructive: true,
2807 ..Default::default()
2808 },
2809 );
2810
2811 let result = pipeline
2812 .dispatch(&tool, &mut ctx, serde_json::json!({"confirm": true}))
2813 .await;
2814 match result {
2815 Err(CoreError::Governance(msg)) => {
2816 assert!(msg.contains("no explicit scope"), "got: {msg}");
2817 assert!(msg.contains("id"), "names the scope field: {msg}");
2818 }
2819 other => panic!("Expected Governance error, got {other:?}"),
2820 }
2821 }
2822
2823 #[tokio::test]
2824 async fn pipeline_firebreak_scope_law_allows_scoped_destructive() {
2825 let pipeline = DispatchPipeline::with_defaults();
2826 let mut ctx = Context::new(BrainWave::Gamma);
2827 let tool = TestTool::new(
2828 "memory.delete",
2829 EffectRow {
2830 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2831 destructive: true,
2832 ..Default::default()
2833 },
2834 );
2835
2836 let result = pipeline
2837 .dispatch(
2838 &tool,
2839 &mut ctx,
2840 serde_json::json!({"confirm": true, "id": "0f0e0d0c-0000-0000-0000-000000000000"}),
2841 )
2842 .await;
2843 assert!(result.is_ok());
2844 }
2845
2846 #[tokio::test]
2847 async fn pipeline_firebreak_caution_disclosed_in_response() {
2848 let pipeline = DispatchPipeline::with_defaults();
2849 let mut ctx = Context::new(BrainWave::Gamma);
2850 let tool = TestTool::new(
2851 "galaxy.transfer",
2852 EffectRow {
2853 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2854 destructive: true,
2855 ..Default::default()
2856 },
2857 )
2858 .with_output(serde_json::json!({"status": "success"}));
2859
2860 let result = pipeline
2861 .dispatch(
2862 &tool,
2863 &mut ctx,
2864 serde_json::json!({"confirm": true, "from_galaxy": "codex", "note": "mv old new"}),
2865 )
2866 .await;
2867 let output = result.expect("caution must not block");
2868 let advisories = output
2869 .get("firebreak")
2870 .and_then(|f| f.get("advisories"))
2871 .and_then(|a| a.as_array())
2872 .expect("advisories must reach the response");
2873 assert_eq!(advisories.len(), 1);
2874 }
2875
2876 #[tokio::test]
2877 async fn pipeline_firebreak_dangerous_escalates_off_confirm_gate() {
2878 let pipeline = DispatchPipeline::with_defaults();
2882 let mut ctx = Context::new(BrainWave::Gamma);
2883 let tool = TestTool::new(
2884 "spawn_tool",
2885 EffectRow {
2886 spawns: true,
2887 ..Default::default()
2888 },
2889 );
2890
2891 let blocked = pipeline
2892 .dispatch(
2893 &tool,
2894 &mut ctx,
2895 serde_json::json!({"cmd": "sudo rm -r /tmp/build"}),
2896 )
2897 .await;
2898 match blocked {
2899 Err(CoreError::Governance(msg)) => {
2900 assert!(msg.contains("dangerous"), "got: {msg}");
2901 assert!(msg.contains("confirm"), "got: {msg}");
2902 }
2903 other => panic!("Expected Governance error, got {other:?}"),
2904 }
2905
2906 let allowed = pipeline
2907 .dispatch(
2908 &tool,
2909 &mut ctx,
2910 serde_json::json!({"cmd": "sudo rm -r /tmp/build", "confirm": true}),
2911 )
2912 .await;
2913 assert!(allowed.is_ok());
2914 }
2915
2916 #[tokio::test]
2917 async fn pipeline_firebreak_never_scans_prose() {
2918 let pipeline = DispatchPipeline::with_defaults();
2921 let mut ctx = Context::new(BrainWave::Gamma);
2922 let tool = TestTool::new(
2923 "memory.create",
2924 EffectRow {
2925 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2926 ..Default::default()
2927 },
2928 );
2929
2930 let result = pipeline
2931 .dispatch(
2932 &tool,
2933 &mut ctx,
2934 serde_json::json!({"content": "incident: operator ran rm -rf / on the store"}),
2935 )
2936 .await;
2937 assert!(result.is_ok(), "prose is never vetoed");
2938 }
2939
2940 #[tokio::test]
2941 async fn pipeline_firebreak_disarmable_per_pipeline() {
2942 let pipeline = DispatchPipeline::with_defaults()
2943 .with_firebreak_option(None::<Arc<wm_governance::Firebreak>>);
2944 let mut ctx = Context::new(BrainWave::Gamma);
2945 let tool = TestTool::new(
2946 "destructive_tool",
2947 EffectRow {
2948 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2949 destructive: true,
2950 ..Default::default()
2951 },
2952 );
2953
2954 let result = pipeline
2956 .dispatch(&tool, &mut ctx, serde_json::json!({}))
2957 .await;
2958 assert!(result.is_err());
2959
2960 let result = pipeline
2962 .dispatch(
2963 &tool,
2964 &mut ctx,
2965 serde_json::json!({"confirm": true, "cmd": "rm -rf /"}),
2966 )
2967 .await;
2968 assert!(result.is_ok(), "disarmed pipeline must not veto");
2969 }
2970
2971 #[tokio::test]
2972 async fn pipeline_write_audit_records_destructive_confirm() {
2973 let tmp = tempfile::tempdir().unwrap();
2976 let store = Arc::new(wm_memory::MemoryStore::open_default(tmp.path()).unwrap());
2977 let journal = Arc::new(WriteAuditJournal::with_flush_threshold(store.clone(), 0).unwrap());
2978 let pipeline = DispatchPipeline::with_defaults().with_write_audit(journal.clone());
2979 let mut ctx = Context::new(BrainWave::Gamma);
2980
2981 let tool = TestTool::new(
2982 "memory.delete",
2983 EffectRow {
2984 writes: vec![wm_core::Resource::Galaxy("codex".into())],
2985 destructive: true,
2986 ..Default::default()
2987 },
2988 )
2989 .with_store(store);
2990
2991 let result = pipeline
2992 .dispatch(
2993 &tool,
2994 &mut ctx,
2995 serde_json::json!({"confirm": true, "id": "abc-123"}),
2996 )
2997 .await;
2998 assert!(result.is_ok());
2999
3000 let entries = journal.scan_entries().unwrap();
3001 assert_eq!(entries.len(), 1);
3002 assert_eq!(
3003 entries[0].confirmed,
3004 Some(true),
3005 "destructive entry must record the confirm"
3006 );
3007 }
3008
3009 #[tokio::test]
3012 async fn pipeline_compartment_production_blocks_runtime_galaxy_write_bypass() {
3013 let pipeline = DispatchPipeline::with_defaults();
3016 let mut ctx = Context::new(BrainWave::Gamma);
3017 ctx.compartment = Some("production".into());
3018 let tool = TestTool::new(
3019 "memory_update",
3020 EffectRow {
3021 writes: vec![wm_core::Resource::Galaxy("codex".into())],
3022 ..Default::default()
3023 },
3024 );
3025
3026 let args = serde_json::json!({"galaxy": "karma"});
3027 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3028 assert!(result.is_err());
3029 match result {
3030 Err(CoreError::Governance(msg)) => {
3031 assert!(msg.contains("production"));
3032 assert!(msg.contains("karma"));
3033 assert!(msg.contains("runtime"));
3034 }
3035 other => panic!("Expected Governance error, got {other:?}"),
3036 }
3037 }
3038
3039 #[tokio::test]
3040 async fn pipeline_compartment_production_blocks_runtime_galaxy_read_bypass() {
3041 let pipeline = DispatchPipeline::with_defaults();
3044 let mut ctx = Context::new(BrainWave::Gamma);
3045 ctx.compartment = Some("production".into());
3046 let tool = TestTool::new(
3047 "memory_read",
3048 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3049 );
3050
3051 let args = serde_json::json!({"galaxy": "karma"});
3052 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3053 assert!(result.is_err());
3054 match result {
3055 Err(CoreError::Governance(msg)) => {
3056 assert!(msg.contains("production"));
3057 assert!(msg.contains("karma"));
3058 assert!(msg.contains("runtime"));
3059 }
3060 other => panic!("Expected Governance error, got {other:?}"),
3061 }
3062 }
3063
3064 #[tokio::test]
3065 async fn pipeline_compartment_production_allows_runtime_galaxy_same_as_declared() {
3066 let pipeline = DispatchPipeline::with_defaults();
3069 let mut ctx = Context::new(BrainWave::Gamma);
3070 ctx.compartment = Some("production".into());
3071 let tool = TestTool::new(
3072 "memory_read",
3073 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3074 );
3075
3076 let args = serde_json::json!({"galaxy": "codex"});
3077 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3078 assert!(result.is_ok());
3079 }
3080
3081 #[tokio::test]
3082 async fn pipeline_compartment_no_restriction_allows_runtime_galaxy() {
3083 let pipeline = DispatchPipeline::with_defaults();
3085 let mut ctx = Context::new(BrainWave::Gamma);
3086 let tool = TestTool::new(
3087 "memory_read",
3088 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3089 );
3090
3091 let args = serde_json::json!({"galaxy": "karma"});
3092 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3093 assert!(result.is_ok());
3094 }
3095
3096 #[tokio::test]
3097 async fn pipeline_compartment_production_allows_runtime_memory_galaxy() {
3098 let pipeline = DispatchPipeline::with_defaults();
3101 let mut ctx = Context::new(BrainWave::Gamma);
3102 ctx.compartment = Some("production".into());
3103 let tool = TestTool::new(
3104 "memory_read",
3105 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3106 );
3107
3108 let args = serde_json::json!({"galaxy": "research"});
3109 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3110 assert!(result.is_ok());
3111 }
3112
3113 #[tokio::test]
3114 async fn pipeline_compartment_production_blocks_runtime_system_galaxy() {
3115 let pipeline = DispatchPipeline::with_defaults();
3118 let mut ctx = Context::new(BrainWave::Gamma);
3119 ctx.compartment = Some("production".into());
3120 let tool = TestTool::new(
3121 "memory_read",
3122 EffectRow::read_only(vec![wm_core::Resource::Galaxy("codex".into())]),
3123 );
3124
3125 let args = serde_json::json!({"galaxy": "karma"});
3126 let result = pipeline.dispatch(&tool, &mut ctx, args).await;
3127 assert!(result.is_err());
3128 match result {
3129 Err(CoreError::Governance(msg)) => {
3130 assert!(msg.contains("production"));
3131 assert!(msg.contains("karma"));
3132 assert!(msg.contains("runtime"));
3133 }
3134 other => panic!("Expected Governance error, got {other:?}"),
3135 }
3136 }
3137
3138 #[tokio::test]
3139 async fn benchmark_pipeline_overhead() {
3140 let pipeline = DispatchPipeline::with_defaults();
3141 let tool = TestTool::new("bench_tool", EffectRow::pure());
3142 let args = Args::default();
3143
3144 for _ in 0..100 {
3146 let mut ctx = Context::new(BrainWave::Gamma);
3147 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3148 }
3149
3150 let n = 10_000;
3152 let start = std::time::Instant::now();
3153 for _ in 0..n {
3154 let mut ctx = Context::new(BrainWave::Gamma);
3155 let _ = pipeline.dispatch(&tool, &mut ctx, args.clone()).await;
3156 }
3157 let pipeline_ns = start.elapsed().as_nanos() / n;
3158
3159 let start = std::time::Instant::now();
3161 for _ in 0..n {
3162 let mut ctx = Context::new(BrainWave::Gamma);
3163 let _ = tool.call(&mut ctx, args.clone()).await;
3164 }
3165 let direct_ns = start.elapsed().as_nanos() / n;
3166
3167 let overhead_ns = pipeline_ns.saturating_sub(direct_ns);
3168 println!(
3169 "\n Pipeline: {pipeline_ns} ns/call | Direct: {direct_ns} ns/call | Overhead: {overhead_ns} ns/call"
3170 );
3171
3172 #[cfg(not(debug_assertions))]
3176 assert!(
3177 overhead_ns < 5_000,
3178 "Pipeline overhead {overhead_ns} ns/call exceeds 5µs budget"
3179 );
3180 }
3181}