1use std::sync::Arc;
38use wm_core::{Galaxy, Result, time};
39use wm_memory::{MemoryStore, content_hash, typology};
40
41#[derive(Debug, Default)]
43pub struct GateOutcome {
44 pub disclosure: Option<serde_json::Value>,
47 pub short_circuit: Option<serde_json::Value>,
49}
50
51fn jnum(v: f32) -> serde_json::Value {
54 let d = f64::from(v);
55 serde_json::json!((d * 1000.0).round() / 1000.0)
56}
57
58pub fn parse_importance_value(
66 value: Option<&serde_json::Value>,
67) -> std::result::Result<Option<f32>, String> {
68 match value {
69 None | Some(serde_json::Value::Null) => Ok(None),
70 Some(serde_json::Value::Number(n)) => n
71 .as_f64()
72 .ok_or_else(|| format!("importance must be a number in 0.0-1.0, got: {n}"))
73 .and_then(validate_importance),
74 Some(serde_json::Value::String(s)) => {
75 let trimmed = s.trim();
76 if trimmed.is_empty() {
77 return Ok(None);
78 }
79 trimmed
80 .parse::<f64>()
81 .map_err(|_| format!("importance must be a number in 0.0-1.0, got: \"{s}\""))
82 .and_then(validate_importance)
83 }
84 Some(other) => Err(format!(
85 "importance must be a number in 0.0-1.0, got: {other}"
86 )),
87 }
88}
89
90fn validate_importance(v: f64) -> std::result::Result<Option<f32>, String> {
95 if v.is_finite() && (0.0..=1.0).contains(&v) {
96 Ok(Some(v as f32))
97 } else {
98 Err(format!("importance must be a number in 0.0-1.0, got: {v}"))
99 }
100}
101
102pub struct WriteGate {
104 store: Arc<MemoryStore>,
105}
106
107impl WriteGate {
108 pub const fn new(store: Arc<MemoryStore>) -> Self {
109 Self { store }
110 }
111
112 pub fn enforce(&self, tool_name: &str, args: &mut serde_json::Value) -> Result<GateOutcome> {
118 match tool_name {
119 "memory.create" => self.gate_create(args),
120 "memory.batch_create" => self.gate_batch(args),
121 "memory.update" => self.gate_update(args),
122 _ => Ok(GateOutcome::default()),
123 }
124 }
125
126 fn gate_create(&self, args: &mut serde_json::Value) -> Result<GateOutcome> {
127 let Some(content) = args
130 .get("content")
131 .and_then(serde_json::Value::as_str)
132 .map(str::to_string)
133 else {
134 return Ok(GateOutcome::default());
137 };
138 let tags: Vec<String> = args
139 .get("tags")
140 .and_then(serde_json::Value::as_array)
141 .map(|a| {
142 a.iter()
143 .filter_map(|v| v.as_str().map(String::from))
144 .collect()
145 })
146 .unwrap_or_default();
147 let galaxy = parse_galaxy_lenient(args.get("galaxy"));
148
149 let class = typology::detect_class(&content, &tags);
150 let mut disclosure = serde_json::Map::new();
151
152 if let Some(class) = class {
155 let raw_importance = args.get("importance");
156 let parsed =
157 parse_importance_value(raw_importance).map_err(wm_core::CoreError::InvalidArgs)?;
158 if matches!(raw_importance, Some(serde_json::Value::String(_))) && parsed.is_some() {
159 disclosure.insert("importance_from_string".into(), serde_json::json!(true));
162 }
163 let requested = parsed.unwrap_or(0.5);
164 let policy = typology::apply_class_policy(class, requested);
165 if (policy - requested).abs() > f32::EPSILON {
166 disclosure.insert("importance_capped".into(), serde_json::json!(true));
167 disclosure.insert("importance_before".into(), serde_json::json!(requested));
168 }
169 args["importance"] = jnum(policy);
170 disclosure.insert("class".into(), serde_json::json!(class.as_str()));
171 disclosure.insert(
172 "tier".into(),
173 serde_json::json!(typology::initial_tier(class).as_str()),
174 );
175 }
176
177 if let Some(galaxy) = galaxy {
179 let hash = content_hash(&content);
180 match self.store.find_by_content_hash(galaxy, &hash) {
181 Ok(Some(id)) => {
182 let existing = self.store.get(galaxy, id)?;
183 if let Some(mut row) = existing {
184 row.metadata.dup_count += 1;
185 row.metadata.accessed_at =
186 chrono::DateTime::from_timestamp_millis(time::now_unix_millis())
187 .unwrap_or_else(chrono::Utc::now);
188 row.metadata.importance /= 1.0 + row.metadata.dup_count as f32;
189 let dup_count = row.metadata.dup_count;
190 let importance = row.metadata.importance;
191 let id = row.metadata.id.to_string();
192 self.store.put(galaxy, &row)?;
193 tracing::info!(
194 id = %id,
195 dup_count,
196 "write gate: duplicate content detected — existing row bumped, write prevented"
197 );
198 disclosure.insert("deduplicated".into(), serde_json::json!(true));
199 let mut short_circuit = serde_json::json!({
200 "status": "deduplicated",
201 "id": id,
202 "dup_count": dup_count,
203 "importance": jnum(importance),
204 "message": "identical content already exists — existing row's dup_count bumped and importance decayed; nothing inserted",
205 });
206 short_circuit["write_gate"] = serde_json::Value::Object(disclosure);
209 return Ok(GateOutcome {
210 disclosure: None,
211 short_circuit: Some(short_circuit),
212 });
213 }
214 }
215 Ok(None) => {}
216 Err(e) => {
217 tracing::warn!(error = %e, "write gate: dedup lookup failed — write proceeds");
221 disclosure.insert("dedup_lookup_failed".into(), serde_json::json!(true));
222 }
223 }
224 }
225
226 let disclosure = if disclosure.is_empty() {
227 None
228 } else {
229 Some(serde_json::Value::Object(disclosure))
230 };
231 Ok(GateOutcome {
232 disclosure,
233 short_circuit: None,
234 })
235 }
236
237 fn gate_update(&self, args: &mut serde_json::Value) -> Result<GateOutcome> {
252 let galaxy = if args.get("galaxy").is_none() {
253 Galaxy::Codex
255 } else {
256 match parse_galaxy_lenient(args.get("galaxy")) {
257 Some(g) => g,
258 None => return Ok(GateOutcome::default()),
259 }
260 };
261 let id = args
262 .get("id")
263 .and_then(|v| v.as_str())
264 .and_then(|s| s.parse::<wm_memory::MemoryId>().ok());
265 let Some(id) = id else {
266 return Ok(GateOutcome::default());
267 };
268 let existing = match self.store.get(galaxy, id) {
269 Ok(Some(row)) => row,
270 _ => return Ok(GateOutcome::default()),
271 };
272
273 let content = args
274 .get("content")
275 .and_then(|v| v.as_str())
276 .map_or_else(|| existing.content.clone(), str::to_string);
277 let tags: Vec<String> = args.get("tags").and_then(|v| v.as_array()).map_or_else(
278 || existing.metadata.tags.clone(),
279 |a| {
280 a.iter()
281 .filter_map(|v| v.as_str().map(String::from))
282 .collect()
283 },
284 );
285
286 let class = existing
287 .metadata
288 .class
289 .or_else(|| typology::detect_class(&content, &tags));
290 let Some(class) = class else {
291 return Ok(GateOutcome::default());
292 };
293
294 let requested = match parse_importance_value(args.get("importance"))
299 .map_err(wm_core::CoreError::InvalidArgs)?
300 {
301 Some(v) => v,
302 None => existing.metadata.importance,
303 };
304 let policy = typology::apply_class_policy(class, requested);
305
306 let mut disclosure = serde_json::Map::new();
307 disclosure.insert("class".into(), serde_json::json!(class.as_str()));
308 disclosure.insert(
309 "tier".into(),
310 serde_json::json!(typology::initial_tier(class).as_str()),
311 );
312 if (policy - requested).abs() > f32::EPSILON {
313 disclosure.insert("importance_capped".into(), serde_json::json!(true));
314 disclosure.insert("importance_before".into(), serde_json::json!(requested));
315 args["importance"] = jnum(policy);
316 }
317 Ok(GateOutcome {
318 disclosure: Some(serde_json::Value::Object(disclosure)),
319 short_circuit: None,
320 })
321 }
322
323 fn gate_batch(&self, args: &mut serde_json::Value) -> Result<GateOutcome> {
324 let galaxy = parse_galaxy_lenient(args.get("galaxy"));
325 let Some(items) = args.get_mut("items").and_then(|v| v.as_array_mut()) else {
326 return Ok(GateOutcome::default());
327 };
328 let mut dropped = 0usize;
329 let mut capped = 0usize;
330 let mut classes: Vec<&'static str> = Vec::new();
331
332 items.retain_mut(|item| {
334 let Some(content) = item
335 .get("content")
336 .and_then(|v| v.as_str())
337 .map(str::to_string)
338 else {
339 return true; };
341 let tags: Vec<String> = item
342 .get("tags")
343 .and_then(serde_json::Value::as_array)
344 .map(|a| {
345 a.iter()
346 .filter_map(|v| v.as_str().map(String::from))
347 .collect()
348 })
349 .unwrap_or_default();
350
351 if let Some(class) = typology::detect_class(&content, &tags) {
352 let requested = item
353 .get("importance")
354 .and_then(serde_json::Value::as_f64)
355 .map_or(0.5, |v| v as f32);
356 let policy = typology::apply_class_policy(class, requested);
357 if (policy - requested).abs() > f32::EPSILON {
358 capped += 1;
359 }
360 item["importance"] = jnum(policy);
361 if !classes.contains(&class.as_str()) {
362 classes.push(class.as_str());
363 }
364 }
365
366 if let Some(galaxy) = galaxy {
367 let hash = content_hash(&content);
368 match self.store.find_by_content_hash(galaxy, &hash) {
369 Ok(Some(_)) => {
370 dropped += 1;
371 return false;
372 }
373 Ok(None) => {}
374 Err(e) => {
375 tracing::warn!(error = %e, "write gate: batch dedup lookup failed — item kept");
376 }
377 }
378 }
379 true
380 });
381
382 let disclosure = if dropped == 0 && capped == 0 && classes.is_empty() {
383 None
384 } else {
385 Some(serde_json::json!({
386 "batch_items_dropped": dropped,
387 "batch_items_capped": capped,
388 "classes": classes,
389 }))
390 };
391 Ok(GateOutcome {
392 disclosure,
393 short_circuit: None,
394 })
395 }
396}
397
398fn parse_galaxy_lenient(v: Option<&serde_json::Value>) -> Option<Galaxy> {
402 let s = v?.as_str()?;
403 if s.is_empty() {
404 return Some(Galaxy::Codex);
405 }
406 Galaxy::from_db_name(&s.to_lowercase()).or_else(|| Galaxy::from_db_name(s))
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use wm_memory::Memory;
413
414 #[test]
415 fn importance_parses_numeric_strings_and_rejects_garbage() {
416 use serde_json::json;
417 assert_eq!(
418 parse_importance_value(Some(&json!(0.9))).unwrap(),
419 Some(0.9_f32)
420 );
421 assert_eq!(
424 parse_importance_value(Some(&json!("0.9"))).unwrap(),
425 Some(0.9_f32)
426 );
427 assert_eq!(parse_importance_value(Some(&json!(" "))).unwrap(), None);
428 assert_eq!(parse_importance_value(None).unwrap(), None);
429 assert!(parse_importance_value(Some(&json!("high"))).is_err());
430 assert!(parse_importance_value(Some(&json!(true))).is_err());
431 }
432
433 struct EchoTool;
436 #[async_trait::async_trait]
437 impl wm_core::Tool for EchoTool {
438 fn name(&self) -> &str {
439 "memory.create"
440 }
441 fn gana(&self) -> wm_core::Gana {
442 wm_core::Gana::Heart
443 }
444 fn effects(&self) -> &wm_core::EffectRow {
445 static ROW: std::sync::OnceLock<wm_core::EffectRow> = std::sync::OnceLock::new();
446 ROW.get_or_init(wm_core::EffectRow::pure)
447 }
448 async fn call(
449 &self,
450 _ctx: &mut wm_core::Context,
451 args: wm_core::Args,
452 ) -> wm_core::Result<wm_core::Output> {
453 Ok(args)
454 }
455 fn stats(&self) -> &wm_core::ToolStats {
456 static STATS: std::sync::OnceLock<wm_core::ToolStats> = std::sync::OnceLock::new();
457 STATS.get_or_init(wm_core::ToolStats::default)
458 }
459 }
460
461 fn gated_pipeline(store: Arc<MemoryStore>) -> crate::pipeline::DispatchPipeline {
462 crate::pipeline::DispatchPipeline::new(
463 Arc::new(crate::rate_limiter::RateLimiter::new(1000, 100, 0)),
464 Arc::new(crate::circuit_breaker::CircuitBreakerRegistry::default()),
465 Arc::new(wm_governance::DharmaGate::default()),
466 None,
467 )
468 .with_write_gate(Arc::new(WriteGate::new(store)))
469 }
470
471 fn gate() -> (tempfile::TempDir, WriteGate, Arc<MemoryStore>) {
472 let dir = tempfile::tempdir().unwrap();
473 let path = dir.path().join("lmdb");
474 std::fs::create_dir_all(&path).unwrap();
475 let store = Arc::new(MemoryStore::open_default(path).unwrap());
476 let g = WriteGate::new(store.clone());
477 (dir, g, store)
478 }
479
480 fn create_args(content: &str) -> serde_json::Value {
481 serde_json::json!({"content": content, "galaxy": "codex"})
482 }
483
484 #[test]
485 fn telemetry_template_caps_importance() {
486 let (_d, g, _s) = gate();
487 let mut args = create_args("## Auto-logged Friction: dispatch error\n\nbody");
488 args["importance"] = serde_json::json!(0.9);
489 let outcome = g.enforce("memory.create", &mut args).unwrap();
490 assert!(outcome.short_circuit.is_none());
491 assert_eq!(args["importance"], serde_json::json!(0.40));
492 let d = outcome.disclosure.unwrap();
493 assert_eq!(d["class"], "telemetry");
494 assert_eq!(d["importance_capped"], true);
495 }
496
497 #[test]
498 fn unrecognized_content_passes_untouched() {
499 let (_d, g, _s) = gate();
500 let mut args = create_args("a normal thought about kumquats");
501 args["importance"] = serde_json::json!(0.9);
502 let outcome = g.enforce("memory.create", &mut args).unwrap();
503 assert!(outcome.disclosure.is_none());
504 assert_eq!(args["importance"], serde_json::json!(0.9));
505 }
506
507 #[test]
508 fn out_of_scope_tools_pass_untouched() {
509 let (_d, g, _s) = gate();
510 let mut args = create_args("## Auto-logged Friction: x");
511 let outcome = g.enforce("memory.search", &mut args).unwrap();
512 assert!(outcome.disclosure.is_none());
513 assert!(outcome.short_circuit.is_none());
514 assert!(args.get("importance").is_none());
515 }
516
517 fn update_args(id: &str) -> serde_json::Value {
518 serde_json::json!({"galaxy": "codex", "id": id})
519 }
520
521 #[test]
522 fn update_caps_importance_by_stamped_class() {
523 let (_d, g, store) = gate();
524 let mut tel = Memory::new(
525 Galaxy::Codex,
526 "## Auto-logged Friction: dispatch error\n\nbody".into(),
527 );
528 tel.metadata.importance = 0.9;
529 store.put(Galaxy::Codex, &tel).unwrap();
530
531 let mut args = update_args(&tel.metadata.id.to_string());
532 args["importance"] = serde_json::json!(0.95);
533 let outcome = g.enforce("memory.update", &mut args).unwrap();
534 assert!(outcome.short_circuit.is_none());
535 assert_eq!(args["importance"], serde_json::json!(0.40));
536 let d = outcome.disclosure.unwrap();
537 assert_eq!(d["class"], "telemetry");
538 assert_eq!(d["importance_capped"], true);
539 }
540
541 #[test]
542 fn update_detects_class_on_unstamped_rows() {
543 let (_d, g, store) = gate();
544 let mut tel = Memory::new(
547 Galaxy::Codex,
548 "## Auto-logged Friction: dispatch error\n\nbody".into(),
549 );
550 tel.metadata.class = None;
551 tel.metadata.importance = 0.9;
552 store.put(Galaxy::Codex, &tel).unwrap();
553
554 let mut args = update_args(&tel.metadata.id.to_string());
555 args["importance"] = serde_json::json!(0.95);
556 let outcome = g.enforce("memory.update", &mut args).unwrap();
557 assert_eq!(args["importance"], serde_json::json!(0.40));
558 assert_eq!(outcome.disclosure.unwrap()["class"], "telemetry");
559 }
560
561 #[test]
562 fn update_content_change_into_capped_class_caps_existing_importance() {
563 let (_d, g, store) = gate();
564 let mut mem = Memory::new(Galaxy::Codex, "a normal thought".into());
567 mem.metadata.class = None;
568 mem.metadata.importance = 0.9;
569 store.put(Galaxy::Codex, &mem).unwrap();
570
571 let mut args = update_args(&mem.metadata.id.to_string());
572 args["content"] = serde_json::json!("## Auto-logged Friction: now telemetry");
573 let outcome = g.enforce("memory.update", &mut args).unwrap();
574 assert_eq!(args["importance"], serde_json::json!(0.40));
575 assert_eq!(outcome.disclosure.unwrap()["importance_capped"], true);
576 }
577
578 #[test]
579 fn update_unrecognized_content_passes_untouched() {
580 let (_d, g, store) = gate();
581 let mut mem = Memory::new(Galaxy::Codex, "a normal thought".into());
582 mem.metadata.class = None;
583 mem.metadata.importance = 0.9;
584 store.put(Galaxy::Codex, &mem).unwrap();
585
586 let mut args = update_args(&mem.metadata.id.to_string());
587 args["importance"] = serde_json::json!(0.95);
588 let outcome = g.enforce("memory.update", &mut args).unwrap();
589 assert!(outcome.disclosure.is_none());
590 assert_eq!(args["importance"], serde_json::json!(0.95));
591 }
592
593 #[test]
594 fn update_missing_row_passes_through_for_tool_error() {
595 let (_d, g, _s) = gate();
596 let mut args = update_args("99999999-9999-9999-9999-999999999999");
597 args["importance"] = serde_json::json!(0.95);
598 let outcome = g.enforce("memory.update", &mut args).unwrap();
599 assert!(outcome.disclosure.is_none());
600 assert!(outcome.short_circuit.is_none());
601 assert_eq!(args["importance"], serde_json::json!(0.95));
603 }
604
605 #[test]
606 fn dedup_short_circuits_and_bumps_existing_row() {
607 let (_d, g, store) = gate();
608 let mut existing = Memory::new(Galaxy::Codex, "identical body".into());
610 existing.metadata.importance = 0.9;
611 store.put(Galaxy::Codex, &existing).unwrap();
612
613 let mut args = create_args("identical body");
614 let outcome = g.enforce("memory.create", &mut args).unwrap();
615 let sc = outcome.short_circuit.expect("dedup must short-circuit");
616 assert_eq!(sc["status"], "deduplicated");
617 assert_eq!(sc["dup_count"], 1);
618 assert_eq!(sc["id"], existing.metadata.id.to_string());
619
620 let row = store
623 .get(Galaxy::Codex, existing.metadata.id)
624 .unwrap()
625 .unwrap();
626 assert_eq!(row.metadata.dup_count, 1);
627 assert!((row.metadata.importance - 0.45).abs() < f32::EPSILON);
628 assert_eq!(
629 store.count(Galaxy::Codex).unwrap(),
630 1,
631 "duplicate insert must be prevented"
632 );
633
634 let mut args2 = create_args("identical body");
636 let outcome2 = g.enforce("memory.create", &mut args2).unwrap();
637 assert_eq!(outcome2.short_circuit.unwrap()["dup_count"], 2);
638 let row2 = store
639 .get(Galaxy::Codex, existing.metadata.id)
640 .unwrap()
641 .unwrap();
642 assert!((row2.metadata.importance - 0.15).abs() < f32::EPSILON);
643 }
644
645 #[test]
646 fn batch_gate_drops_duplicates_and_caps_items() {
647 let (_d, g, store) = gate();
648 let mut existing = Memory::new(Galaxy::Codex, "already here".into());
649 existing.metadata.importance = 0.8;
650 store.put(Galaxy::Codex, &existing).unwrap();
651
652 let mut args = serde_json::json!({
653 "galaxy": "codex",
654 "items": [
655 {"content": "already here"},
656 {"content": "## Friction: noise", "importance": 0.95},
657 {"content": "fresh thought"},
658 ]
659 });
660 let outcome = g.enforce("memory.batch_create", &mut args).unwrap();
661 let d = outcome.disclosure.unwrap();
662 assert_eq!(d["batch_items_dropped"], 1);
663 assert_eq!(d["batch_items_capped"], 1);
664 let items = args["items"].as_array().unwrap();
665 assert_eq!(items.len(), 2, "duplicate item dropped");
666 assert_eq!(items[0]["content"], "## Friction: noise");
667 assert_eq!(items[0]["importance"], serde_json::json!(0.4));
668 assert_eq!(items[1]["content"], "fresh thought");
669 }
670
671 #[test]
672 fn dialogue_floor_applies_to_session_json() {
673 let (_d, g, _s) = gate();
674 let mut args = create_args(r#"{"role":"ai","content":"we decided X","session_id":"s1"}"#);
675 args["importance"] = serde_json::json!(0.5);
676 let outcome = g.enforce("memory.create", &mut args).unwrap();
677 assert_eq!(args["importance"], serde_json::json!(0.75));
678 let d = outcome.disclosure.unwrap();
679 assert_eq!(d["class"], "dialogue");
680 }
681
682 #[tokio::test]
685 async fn pipeline_end_to_end_rewrite_and_disclosure() {
686 let (_d, _g, store) = gate();
687 let pipeline = gated_pipeline(store);
688 let mut ctx = wm_core::Context::new(wm_core::BrainWave::Gamma);
689 let args = serde_json::json!({
690 "content": "## Friction: noisy dispatch",
691 "galaxy": "codex",
692 "importance": 0.9,
693 });
694 let out = pipeline.dispatch(&EchoTool, &mut ctx, args).await.unwrap();
695 assert_eq!(out["importance"], serde_json::json!(0.4));
697 assert_eq!(out["write_gate"]["class"], "telemetry");
699 assert_eq!(out["write_gate"]["tier"], "working");
700 assert_eq!(out["write_gate"]["importance_capped"], true);
701 }
702
703 #[tokio::test]
707 async fn pipeline_end_to_end_dedup_short_circuit() {
708 let (_d, _g, store) = gate();
709 let mut existing = Memory::new(wm_core::Galaxy::Codex, "the same thing twice".into());
710 existing.metadata.importance = 0.8;
711 store.put(wm_core::Galaxy::Codex, &existing).unwrap();
712
713 let pipeline = gated_pipeline(store);
714 let mut ctx = wm_core::Context::new(wm_core::BrainWave::Gamma);
715 let args = serde_json::json!({
716 "content": "the same thing twice",
717 "galaxy": "codex",
718 });
719 let out = pipeline.dispatch(&EchoTool, &mut ctx, args).await.unwrap();
720 assert_eq!(out["status"], "deduplicated");
721 assert_eq!(out["dup_count"], 1);
722 assert_eq!(out["id"], existing.metadata.id.to_string());
723 assert_eq!(out["write_gate"]["deduplicated"], true);
724 }
725
726 #[tokio::test]
727 async fn dedup_store_error_is_disclosed_not_fatal() {
728 let dir = tempfile::tempdir().unwrap();
731 let path = dir.path().join("lmdb");
732 std::fs::create_dir_all(&path).unwrap();
733 let store = Arc::new(MemoryStore::open_default(&path).unwrap());
734 let g = WriteGate::new(store.clone());
735 drop(store); let mut args = create_args("probe content");
741 let outcome = g.enforce("memory.create", &mut args).unwrap();
742 assert!(outcome.short_circuit.is_none());
743 }
744}