stasis/application/use_cases/
identity_memory_service.rs1use std::sync::Arc;
2
3use crate::domain::errors::Result;
4use crate::ports::outbound::memory::identity_memory_models::{
5 CommitEntityUpdateRequest, CommitEntityUpdateResponse, GetIdentityContextRequest,
6 GetIdentityContextResponse, ListEntityHistoryRequest, ListEntityHistoryResponse,
7 ProposeEntityUpdateRequest, ProposeEntityUpdateResponse, RollbackEntityVersionRequest,
8 RollbackEntityVersionResponse, UpdateTier,
9};
10use crate::ports::outbound::memory::identity_memory_store::IdentityMemoryStore;
11
12#[derive(Clone)]
13pub struct IdentityMemoryService {
14 store: Arc<dyn IdentityMemoryStore>,
15}
16
17#[derive(Clone, Debug, Default)]
18pub struct ProposeAndCommitRequest {
19 pub proposal_request: ProposeEntityUpdateRequest,
20 pub expected_version: i32,
21 pub approver: Option<String>,
22}
23
24#[derive(Clone, Debug, Default)]
25pub struct ProposeAndCommitResponse {
26 pub proposed: ProposeEntityUpdateResponse,
27 pub commits: Vec<CommitEntityUpdateResponse>,
28}
29
30impl IdentityMemoryService {
31 pub fn new(store: Arc<dyn IdentityMemoryStore>) -> Self {
32 Self { store }
33 }
34
35 pub async fn get_identity_context(
36 &self,
37 request: &GetIdentityContextRequest,
38 ) -> Result<GetIdentityContextResponse> {
39 self.store.get_identity_context(request).await
40 }
41
42 pub async fn propose_entity_update(
43 &self,
44 request: &ProposeEntityUpdateRequest,
45 ) -> Result<ProposeEntityUpdateResponse> {
46 self.store.propose_entity_update(request).await
47 }
48
49 pub async fn commit_entity_update(
50 &self,
51 request: &CommitEntityUpdateRequest,
52 ) -> Result<CommitEntityUpdateResponse> {
53 self.store.commit_entity_update(request).await
54 }
55
56 pub async fn list_entity_history(
57 &self,
58 request: &ListEntityHistoryRequest,
59 ) -> Result<ListEntityHistoryResponse> {
60 self.store.list_entity_history(request).await
61 }
62
63 pub async fn rollback_entity_version(
64 &self,
65 request: &RollbackEntityVersionRequest,
66 ) -> Result<RollbackEntityVersionResponse> {
67 self.store.rollback_entity_version(request).await
68 }
69
70 pub async fn propose_and_commit_autocommit(
71 &self,
72 request: &ProposeAndCommitRequest,
73 ) -> Result<ProposeAndCommitResponse> {
74 let proposed = self
75 .store
76 .propose_entity_update(&request.proposal_request)
77 .await?;
78
79 let mut expected_version = request.expected_version;
80 let mut commits = Vec::new();
81
82 for (idx, proposal_id) in proposed.proposal_ids.iter().enumerate() {
83 let tier = proposed
84 .tiers
85 .get(idx)
86 .copied()
87 .unwrap_or(UpdateTier::ApprovalRequired);
88 if !matches!(tier, UpdateTier::AutoCommit) {
89 continue;
90 }
91
92 let commit = self
93 .store
94 .commit_entity_update(&CommitEntityUpdateRequest {
95 proposal_id: proposal_id.clone(),
96 expected_version,
97 approver: request.approver.clone(),
98 })
99 .await?;
100
101 if commit.committed && let Some(new_version) = commit.new_version {
102 expected_version = new_version;
103 }
104
105 commits.push(commit);
106 }
107
108 Ok(ProposeAndCommitResponse { proposed, commits })
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use std::sync::Arc;
115
116 use chrono::Utc;
117 use serde_json::json;
118
119 use super::{IdentityMemoryService, ProposeAndCommitRequest};
120 use crate::infrastructure::memory::in_memory_identity_memory_store::InMemoryIdentityMemoryStore;
121 use crate::ports::outbound::memory::identity_memory_models::{
122 EntityRef, IdentityEntityType, ProposeEntityUpdateRequest, RelationshipEntity,
123 RelationshipStatus, UpdateSource,
124 };
125
126 #[tokio::test]
127 async fn service_autocommits_only_auto_tier_proposals() {
128 let store = Arc::new(InMemoryIdentityMemoryStore::default());
129 store
130 .upsert_relationship(RelationshipEntity {
131 relationship_id: "rel-1".to_string(),
132 source_entity_ref: EntityRef {
133 entity_type: "PersonaEntity".to_string(),
134 entity_id: "p1".to_string(),
135 },
136 target_entity_ref: EntityRef {
137 entity_type: "UserEntity".to_string(),
138 entity_id: "u1".to_string(),
139 },
140 relationship_kind: "assistant_user".to_string(),
141 status: RelationshipStatus::Active,
142 trust_level: 0.50,
143 confidence: 0.80,
144 strength_score: 0.80,
145 recency_score: 0.60,
146 autonomy_scope: Default::default(),
147 approval_profile_id: None,
148 interruption_policy: Default::default(),
149 escalation_policy: Default::default(),
150 policy_tags: vec![],
151 provenance: UpdateSource::UserDirect,
152 parent_relationship_id: None,
153 governing_relationship_ids: vec![],
154 derived_from_relationship_id: None,
155 last_transition_reason: None,
156 transition_receipt_id: None,
157 version: 1,
158 created_at: Utc::now(),
159 updated_at: Utc::now(),
160 })
161 .expect("seed relationship should succeed");
162
163 let service = IdentityMemoryService::new(store);
164
165 let result = service
166 .propose_and_commit_autocommit(&ProposeAndCommitRequest {
167 proposal_request: ProposeEntityUpdateRequest {
168 entity_type: IdentityEntityType::RelationshipEntity,
169 entity_id: "rel-1".to_string(),
170 patch: json!({
171 "recency_score": 0.9,
172 "autonomy_scope.allow": ["external_posting"]
173 }),
174 source: UpdateSource::ModelInferred,
175 confidence: 0.82,
176 reason: "mixed update".to_string(),
177 actor: "model".to_string(),
178 receipt_id: None,
179 expires_at: None,
180 },
181 expected_version: 1,
182 approver: Some("owner".to_string()),
183 })
184 .await
185 .expect("workflow should succeed");
186
187 assert_eq!(result.proposed.proposal_ids.len(), 2);
188 assert_eq!(result.commits.len(), 1);
189 assert!(result.commits[0].committed);
190 }
191}