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