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