1use std::sync::Arc;
2use std::time::SystemTime;
3
4use kmp_domain::{
5 CaseId, ContextEventChange, ContextEventStore, ContextUpdatedEvent, PortError,
6 ProjectionMutation, ProjectionWriter, Role,
7};
8
9use crate::ApplicationError;
10use crate::commands::memory_projection::memory_projection_mutations;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct UpdateContextChange {
14 pub operation: String,
15 pub entity_kind: String,
16 pub entity_id: String,
17 pub payload_json: String,
18 pub reason: String,
19 pub scopes: Vec<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct UpdateContextCommand {
24 pub root_node_id: String,
25 pub role: String,
26 pub work_item_id: String,
27 pub changes: Vec<UpdateContextChange>,
28 pub expected_revision: Option<u64>,
29 pub expected_content_hash: Option<String>,
30 pub idempotency_key: Option<String>,
31 pub logical_digest: Option<String>,
37 pub requested_by: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct AcceptedVersion {
42 pub revision: u64,
43 pub content_hash: String,
44 pub generator_version: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct UpdateContextOutcome {
49 pub accepted_version: AcceptedVersion,
50 pub replayed: bool,
52 pub replayed_receipt: Option<kmp_domain::StoredCommandReceipt>,
53 pub warnings: Vec<String>,
54}
55
56#[derive(Debug)]
57pub struct UpdateContextUseCase<E, W = NoopProjectionWriter> {
58 event_store: Arc<E>,
59 projection_writer: W,
60 generator_version: &'static str,
61}
62
63#[derive(Debug, Clone, Copy, Default)]
64pub struct NoopProjectionWriter;
65
66impl ProjectionWriter for NoopProjectionWriter {
67 async fn apply_mutations(&self, _mutations: Vec<ProjectionMutation>) -> Result<(), PortError> {
68 Ok(())
69 }
70}
71
72impl<E> UpdateContextUseCase<E, NoopProjectionWriter>
73where
74 E: ContextEventStore + Send + Sync,
75{
76 pub fn new(event_store: Arc<E>, generator_version: &'static str) -> Self {
77 Self {
78 event_store,
79 projection_writer: NoopProjectionWriter,
80 generator_version,
81 }
82 }
83}
84
85impl<E, W> UpdateContextUseCase<E, W>
86where
87 E: ContextEventStore + Send + Sync,
88 W: ProjectionWriter + Send + Sync,
89{
90 pub fn new_with_projection_writer(
91 event_store: Arc<E>,
92 projection_writer: W,
93 generator_version: &'static str,
94 ) -> Self {
95 Self {
96 event_store,
97 projection_writer,
98 generator_version,
99 }
100 }
101
102 pub async fn accepted_outcome(
106 &self,
107 idempotency_key: &str,
108 ) -> Result<Option<kmp_domain::IdempotentOutcome>, ApplicationError> {
109 Ok(self
110 .event_store
111 .find_by_idempotency_key(idempotency_key)
112 .await?)
113 }
114
115 pub async fn execute(
116 &self,
117 command: UpdateContextCommand,
118 ) -> Result<UpdateContextOutcome, ApplicationError> {
119 self.execute_after_read(command, &std::collections::BTreeMap::new())
120 .await
121 }
122
123 pub(crate) async fn execute_after_read(
124 &self,
125 command: UpdateContextCommand,
126 read_revisions: &std::collections::BTreeMap<String, u64>,
127 ) -> Result<UpdateContextOutcome, ApplicationError> {
128 let case_id = CaseId::new(&command.root_node_id)?;
129 let role = Role::new(&command.role)?;
130
131 let content_hash = compute_content_hash(&command.changes);
134
135 if let Some(ref key) = command.idempotency_key
140 && let Some(outcome) = self.event_store.find_by_idempotency_key(key).await?
141 {
142 let same_logical_command = match (&command.logical_digest, &outcome.logical_digest) {
143 (Some(ours), Some(stored)) => ours == stored,
144 _ => outcome.content_hash == content_hash,
145 };
146 if !same_logical_command {
147 return Err(ApplicationError::Ports(kmp_domain::PortError::Conflict(
148 format!("idempotency key '{key}' was already accepted with different content"),
149 )));
150 }
151 return Ok(UpdateContextOutcome {
152 replayed: true,
153 replayed_receipt: outcome.receipt,
154 accepted_version: AcceptedVersion {
155 revision: outcome.revision,
156 content_hash: outcome.content_hash,
157 generator_version: self.generator_version.to_string(),
158 },
159 warnings: vec![],
160 });
161 }
162
163 let current_revision = self
165 .event_store
166 .current_revision(case_id.as_str(), role.as_str())
167 .await?;
168
169 let expected_revision = command.expected_revision.unwrap_or(current_revision);
171 if expected_revision != current_revision {
172 return Err(ApplicationError::RetryableConflict(format!(
173 "expected revision {expected_revision}, current is {current_revision}"
174 )));
175 }
176
177 if let Some(ref expected_hash) = command.expected_content_hash
179 && let Some(ref current_hash) = self
180 .event_store
181 .current_content_hash(case_id.as_str(), role.as_str())
182 .await?
183 && expected_hash != current_hash
184 {
185 return Err(ApplicationError::RetryableConflict(format!(
186 "expected content hash '{expected_hash}', current is '{current_hash}'"
187 )));
188 }
189
190 let mut warnings = Vec::new();
191 if command.changes.is_empty() {
192 warnings.push("no changes supplied; update was accepted as a no-op".to_string());
193 }
194
195 let event = ContextUpdatedEvent {
197 root_node_id: case_id.as_str().to_string(),
198 role: role.as_str().to_string(),
199 revision: current_revision + 1,
200 content_hash: content_hash.clone(),
201 changes: command
202 .changes
203 .iter()
204 .map(|c| ContextEventChange {
205 operation: c.operation.clone(),
206 entity_kind: c.entity_kind.clone(),
207 entity_id: c.entity_id.clone(),
208 payload_json: c.payload_json.clone(),
209 reason: if c.reason.is_empty() {
210 None
211 } else {
212 Some(c.reason.clone())
213 },
214 scopes: c.scopes.clone(),
215 })
216 .collect(),
217 idempotency_key: command.idempotency_key.clone(),
218 logical_digest: command.logical_digest.clone(),
219 requested_by: command.requested_by.clone(),
220 occurred_at: SystemTime::now(),
221 };
222
223 let mut projection_mutations =
224 memory_projection_mutations(&command, current_revision + 1, &content_hash)?;
225 let atomic = self.event_store.commits_projections_atomically();
226 let appended = if atomic {
227 let revisions = read_revisions
228 .iter()
229 .map(|(about, revision)| kmp_domain::ContextRevision {
230 root_node_id: about.clone(),
231 role: "memory".into(),
232 revision: *revision,
233 })
234 .collect();
235 self.event_store
236 .append_projected(
237 event,
238 current_revision,
239 revisions,
240 std::mem::take(&mut projection_mutations),
241 )
242 .await
243 } else {
244 self.event_store.append(event, current_revision).await
245 };
246 let new_revision = appended.map_err(|error| match error {
247 PortError::Conflict(message) => ApplicationError::RetryableConflict(message),
251 other => ApplicationError::Ports(other),
252 })?;
253
254 if !atomic && !projection_mutations.is_empty() {
255 self.projection_writer
256 .apply_mutations(projection_mutations)
257 .await?;
258 }
259
260 Ok(UpdateContextOutcome {
261 replayed: false,
262 replayed_receipt: None,
263 accepted_version: AcceptedVersion {
264 revision: new_revision,
265 content_hash,
266 generator_version: self.generator_version.to_string(),
267 },
268 warnings,
269 })
270 }
271
272 pub(crate) async fn memory_revision(&self, about: &str) -> Result<u64, ApplicationError> {
273 Ok(self.event_store.current_revision(about, "memory").await?)
274 }
275}
276
277fn compute_content_hash(changes: &[UpdateContextChange]) -> String {
280 use sha2::{Digest, Sha256};
281 let mut hasher = Sha256::new();
282 for change in changes {
283 hasher.update(change.operation.as_bytes());
284 hasher.update(change.entity_kind.as_bytes());
285 hasher.update(change.entity_id.as_bytes());
286 hasher.update(change.payload_json.as_bytes());
287 }
288 format!("{:064x}", hasher.finalize())
289}
290
291#[cfg(test)]
292mod tests {
293 use std::sync::Arc;
294
295 use kmp_domain::ProjectionMutation;
296 use kmp_testkit::{InMemoryContextEventStore, InMemoryProjectionWriter};
297
298 use super::*;
299
300 fn event_store() -> Arc<InMemoryContextEventStore> {
301 Arc::new(InMemoryContextEventStore::new())
302 }
303
304 fn use_case(
305 store: Arc<InMemoryContextEventStore>,
306 ) -> UpdateContextUseCase<InMemoryContextEventStore> {
307 UpdateContextUseCase::new(store, "0.1.0")
308 }
309
310 fn sample_change() -> UpdateContextChange {
311 UpdateContextChange {
312 operation: "UPDATE".to_string(),
313 entity_kind: "node_detail".to_string(),
314 entity_id: "node-1".to_string(),
315 payload_json: r#"{"status":"ACTIVE"}"#.to_string(),
316 reason: "test".to_string(),
317 scopes: vec!["graph".to_string()],
318 }
319 }
320
321 fn memory_changes() -> Vec<UpdateContextChange> {
322 vec![
323 UpdateContextChange {
324 operation: "UPSERT".to_string(),
325 entity_kind: "memory_dimension".to_string(),
326 entity_id: "conversation:mcp".to_string(),
327 payload_json: r#"{"id":"conversation:mcp","kind":"conversation","title":"MCP smoke"}"#.to_string(),
328 reason: "test dimension".to_string(),
329 scopes: vec!["memory".to_string()],
330 },
331 UpdateContextChange {
332 operation: "UPSERT".to_string(),
333 entity_kind: "memory_entry".to_string(),
334 entity_id: "claim:mcp".to_string(),
335 payload_json: r#"{"id":"claim:mcp","kind":"claim","text":"MCP ingest materializes into the read model.","coordinates":[{"dimension":"conversation","scope_id":"conversation:mcp","sequence":2,"occurred_at":"2026-05-04T10:00:00Z","valid_from":"2026-05-04T10:00:00Z"}]}"#.to_string(),
336 reason: "test entry".to_string(),
337 scopes: vec!["memory".to_string()],
338 },
339 UpdateContextChange {
340 operation: "UPSERT".to_string(),
341 entity_kind: "memory_relation".to_string(),
342 entity_id: "relation:mcp".to_string(),
343 payload_json: r#"{"from":"claim:old","to":"claim:mcp","rel":"supersedes","class":"evidential","why":"The new memory updates the previous claim.","confidence":"high"}"#.to_string(),
344 reason: "test relation".to_string(),
345 scopes: vec!["memory".to_string()],
346 },
347 UpdateContextChange {
348 operation: "UPSERT".to_string(),
349 entity_kind: "memory_evidence".to_string(),
350 entity_id: "evidence:mcp".to_string(),
351 payload_json: r#"{"id":"evidence:mcp","supports":["claim:mcp"],"text":"Projection writer recorded the detail.","source":"unit-test"}"#.to_string(),
352 reason: "test evidence".to_string(),
353 scopes: vec!["memory".to_string()],
354 },
355 ]
356 }
357
358 #[tokio::test]
359 async fn execute_appends_event_and_returns_revision_one() {
360 let store = event_store();
361 let uc = use_case(Arc::clone(&store));
362
363 let outcome = uc
364 .execute(UpdateContextCommand {
365 root_node_id: "node-1".to_string(),
366 role: "developer".to_string(),
367 work_item_id: "task-1".to_string(),
368 changes: vec![sample_change()],
369 expected_revision: None,
370 expected_content_hash: None,
371 idempotency_key: None,
372 logical_digest: None,
373 requested_by: None,
374 })
375 .await
376 .expect("should succeed");
377
378 assert_eq!(outcome.accepted_version.revision, 1);
379 assert!(!outcome.accepted_version.content_hash.is_empty());
380 assert!(outcome.warnings.is_empty());
381 }
382
383 #[tokio::test]
384 async fn execute_rejects_wrong_expected_revision() {
385 let store = event_store();
386 let uc = use_case(Arc::clone(&store));
387
388 let err = uc
389 .execute(UpdateContextCommand {
390 root_node_id: "node-1".to_string(),
391 role: "developer".to_string(),
392 work_item_id: "task-1".to_string(),
393 changes: vec![sample_change()],
394 expected_revision: Some(99),
395 expected_content_hash: None,
396 idempotency_key: None,
397 logical_digest: None,
398 requested_by: None,
399 })
400 .await;
401
402 let error = err.expect_err("should fail");
403 assert!(matches!(&error, ApplicationError::RetryableConflict(_)));
404 let msg = error.to_string();
405 assert!(msg.contains("expected revision 99"));
406 }
407
408 #[tokio::test]
409 async fn execute_returns_cached_outcome_for_duplicate_idempotency_key() {
410 let store = event_store();
411 let uc = use_case(Arc::clone(&store));
412
413 let first = uc
414 .execute(UpdateContextCommand {
415 root_node_id: "node-1".to_string(),
416 role: "developer".to_string(),
417 work_item_id: "task-1".to_string(),
418 changes: vec![sample_change()],
419 expected_revision: None,
420 expected_content_hash: None,
421 idempotency_key: Some("idem-1".to_string()),
422 logical_digest: None,
423 requested_by: None,
424 })
425 .await
426 .expect("first call should succeed");
427
428 let second = uc
429 .execute(UpdateContextCommand {
430 root_node_id: "node-1".to_string(),
431 role: "developer".to_string(),
432 work_item_id: "task-1".to_string(),
433 changes: vec![sample_change()],
434 expected_revision: None,
435 expected_content_hash: None,
436 idempotency_key: Some("idem-1".to_string()),
437 logical_digest: None,
438 requested_by: None,
439 })
440 .await
441 .expect("second call should return cached outcome");
442
443 assert_eq!(
444 first.accepted_version.revision,
445 second.accepted_version.revision
446 );
447 assert_eq!(
448 first.accepted_version.content_hash,
449 second.accepted_version.content_hash
450 );
451 }
452
453 #[tokio::test]
454 async fn execute_rejects_duplicate_idempotency_key_with_different_content() {
455 let store = event_store();
456 let uc = use_case(Arc::clone(&store));
457
458 uc.execute(UpdateContextCommand {
459 root_node_id: "node-1".to_string(),
460 role: "developer".to_string(),
461 work_item_id: "task-1".to_string(),
462 changes: vec![sample_change()],
463 expected_revision: None,
464 expected_content_hash: None,
465 idempotency_key: Some("idem-1".to_string()),
466 logical_digest: None,
467 requested_by: None,
468 })
469 .await
470 .expect("first call should succeed");
471
472 let mut changed = sample_change();
473 changed.payload_json = r#"{"status":"CHANGED"}"#.to_string();
474 let error = uc
475 .execute(UpdateContextCommand {
476 root_node_id: "node-1".to_string(),
477 role: "developer".to_string(),
478 work_item_id: "task-1".to_string(),
479 changes: vec![changed],
480 expected_revision: None,
481 expected_content_hash: None,
482 idempotency_key: Some("idem-1".to_string()),
483 logical_digest: None,
484 requested_by: None,
485 })
486 .await
487 .expect_err("duplicate idempotency key with changed payload should fail");
488
489 assert!(error.to_string().contains("different content"));
490 }
491
492 #[tokio::test]
493 async fn execute_warns_on_empty_changes() {
494 let store = event_store();
495 let uc = use_case(Arc::clone(&store));
496
497 let outcome = uc
498 .execute(UpdateContextCommand {
499 root_node_id: "node-1".to_string(),
500 role: "developer".to_string(),
501 work_item_id: "task-1".to_string(),
502 changes: vec![],
503 expected_revision: None,
504 expected_content_hash: None,
505 idempotency_key: None,
506 logical_digest: None,
507 requested_by: None,
508 })
509 .await
510 .expect("empty changes should succeed with warning");
511
512 assert_eq!(outcome.warnings.len(), 1);
513 assert!(outcome.warnings[0].contains("no changes supplied"));
514 }
515
516 #[tokio::test]
517 async fn execute_increments_revision_on_sequential_calls() {
518 let store = event_store();
519 let uc = use_case(Arc::clone(&store));
520
521 let first = uc
522 .execute(UpdateContextCommand {
523 root_node_id: "node-1".to_string(),
524 role: "developer".to_string(),
525 work_item_id: "task-1".to_string(),
526 changes: vec![sample_change()],
527 expected_revision: None,
528 expected_content_hash: None,
529 idempotency_key: None,
530 logical_digest: None,
531 requested_by: None,
532 })
533 .await
534 .expect("first should succeed");
535
536 let second = uc
537 .execute(UpdateContextCommand {
538 root_node_id: "node-1".to_string(),
539 role: "developer".to_string(),
540 work_item_id: "task-2".to_string(),
541 changes: vec![sample_change()],
542 expected_revision: Some(1),
543 expected_content_hash: None,
544 idempotency_key: None,
545 logical_digest: None,
546 requested_by: None,
547 })
548 .await
549 .expect("second should succeed");
550
551 assert_eq!(first.accepted_version.revision, 1);
552 assert_eq!(second.accepted_version.revision, 2);
553 }
554
555 #[tokio::test]
556 async fn execute_rejects_wrong_content_hash_precondition() {
557 let store = event_store();
558 let uc = use_case(Arc::clone(&store));
559
560 let first = uc
562 .execute(UpdateContextCommand {
563 root_node_id: "node-1".to_string(),
564 role: "developer".to_string(),
565 work_item_id: "task-1".to_string(),
566 changes: vec![sample_change()],
567 expected_revision: None,
568 expected_content_hash: None,
569 idempotency_key: None,
570 logical_digest: None,
571 requested_by: None,
572 })
573 .await
574 .expect("first should succeed");
575
576 let err = uc
578 .execute(UpdateContextCommand {
579 root_node_id: "node-1".to_string(),
580 role: "developer".to_string(),
581 work_item_id: "task-2".to_string(),
582 changes: vec![sample_change()],
583 expected_revision: Some(1),
584 expected_content_hash: Some("wrong-hash".to_string()),
585 idempotency_key: None,
586 logical_digest: None,
587 requested_by: None,
588 })
589 .await;
590
591 let error = err.expect_err("should fail");
592 assert!(matches!(&error, ApplicationError::RetryableConflict(_)));
593 let msg = error.to_string();
594 assert!(msg.contains("expected content hash"));
595
596 let ok = uc
598 .execute(UpdateContextCommand {
599 root_node_id: "node-1".to_string(),
600 role: "developer".to_string(),
601 work_item_id: "task-2".to_string(),
602 changes: vec![sample_change()],
603 expected_revision: Some(1),
604 expected_content_hash: Some(first.accepted_version.content_hash.clone()),
605 idempotency_key: None,
606 logical_digest: None,
607 requested_by: None,
608 })
609 .await
610 .expect("correct hash should succeed");
611
612 assert_eq!(ok.accepted_version.revision, 2);
613 }
614
615 #[tokio::test]
616 async fn execute_projects_memory_changes_into_read_model_mutations() {
617 let store = event_store();
618 let writer = InMemoryProjectionWriter::default();
619 let uc = UpdateContextUseCase::new_with_projection_writer(
620 Arc::clone(&store),
621 writer.clone(),
622 "0.1.0",
623 );
624
625 uc.execute(UpdateContextCommand {
626 root_node_id: "question:mcp".to_string(),
627 role: "memory".to_string(),
628 work_item_id: "ingest:mcp:1".to_string(),
629 changes: memory_changes(),
630 expected_revision: None,
631 expected_content_hash: None,
632 idempotency_key: Some("ingest:mcp:1".to_string()),
633 logical_digest: None,
634 requested_by: Some("mcp-test".to_string()),
635 })
636 .await
637 .expect("memory ingest should succeed");
638
639 let mutations = writer.mutations().await;
640 assert!(mutations.iter().any(|mutation| matches!(
641 mutation,
642 ProjectionMutation::UpsertNode(node)
643 if node.node_id == "claim:mcp"
644 && node.node_kind == "claim"
645 && node.summary == "MCP ingest materializes into the read model."
646 )));
647 assert!(mutations.iter().any(|mutation| matches!(
648 mutation,
649 ProjectionMutation::UpsertNodeDetail(detail)
650 if detail.node_id == "claim:mcp"
651 && detail.detail == "MCP ingest materializes into the read model."
652 )));
653 assert!(mutations.iter().any(|mutation| matches!(
654 mutation,
655 ProjectionMutation::UpsertNode(node)
656 if node.node_id == "evidence:mcp"
657 && node.node_kind == "memory_evidence"
658 && node.summary == "Projection writer recorded the detail."
659 )));
660 assert!(mutations.iter().any(|mutation| matches!(
661 mutation,
662 ProjectionMutation::UpsertNodeRelation(relation)
663 if relation.source_node_id == "question:mcp"
664 && relation.target_node_id == "claim:mcp"
665 && relation.relation_type == "records"
666 )));
667 assert!(mutations.iter().any(|mutation| matches!(
668 mutation,
669 ProjectionMutation::UpsertNodeRelation(relation)
670 if relation.source_node_id == "conversation:mcp"
671 && relation.target_node_id == "claim:mcp"
672 && relation.relation_type == "contains_entry"
673 && relation.explanation.dimension() == Some("conversation")
674 && relation.explanation.scope_id() == Some("conversation:mcp")
675 && relation.explanation.sequence() == Some(2)
676 && relation.explanation.occurred_at() == Some("2026-05-04T10:00:00Z")
677 && relation.explanation.valid_from() == Some("2026-05-04T10:00:00Z")
678 )));
679 assert!(mutations.iter().any(|mutation| matches!(
680 mutation,
681 ProjectionMutation::UpsertNodeRelation(relation)
682 if relation.source_node_id == "evidence:mcp"
683 && relation.target_node_id == "claim:mcp"
684 && relation.relation_type == "supports"
685 )));
686 assert!(mutations.iter().any(|mutation| matches!(
687 mutation,
688 ProjectionMutation::UpsertNodeRelation(relation)
689 if relation.source_node_id == "claim:old"
690 && relation.target_node_id == "claim:mcp"
691 && relation.relation_type == "supersedes"
692 )));
693 assert!(mutations.iter().any(|mutation| matches!(
694 mutation,
695 ProjectionMutation::UpdateNodeStatus { node_id, status }
696 if node_id == "claim:mcp" && status == "SUPERSEDED"
697 )));
698 }
699
700 #[tokio::test]
701 async fn execute_does_not_reproject_duplicate_idempotency_key() {
702 let store = event_store();
703 let writer = InMemoryProjectionWriter::default();
704 let uc = UpdateContextUseCase::new_with_projection_writer(
705 Arc::clone(&store),
706 writer.clone(),
707 "0.1.0",
708 );
709
710 for _ in 0..2 {
711 uc.execute(UpdateContextCommand {
712 root_node_id: "question:mcp".to_string(),
713 role: "memory".to_string(),
714 work_item_id: "ingest:mcp:1".to_string(),
715 changes: memory_changes(),
716 expected_revision: None,
717 expected_content_hash: None,
718 idempotency_key: Some("ingest:mcp:1".to_string()),
719 logical_digest: None,
720 requested_by: Some("mcp-test".to_string()),
721 })
722 .await
723 .expect("idempotent memory ingest should succeed");
724 }
725
726 let mutations = writer.mutations().await;
727 let claim_nodes = mutations
728 .iter()
729 .filter(|mutation| {
730 matches!(
731 mutation,
732 ProjectionMutation::UpsertNode(node) if node.node_id == "claim:mcp"
733 )
734 })
735 .count();
736 assert_eq!(claim_nodes, 1);
737 }
738}