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