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