stasis-rs 0.2.3

Durable AI orchestration framework with runtime jobs, lineage, and memory integration
Documentation
use std::sync::Arc;

use crate::domain::errors::Result;
use crate::ports::outbound::memory::identity_memory_models::{
    CommitEntityUpdateRequest, CommitEntityUpdateResponse, GetIdentityContextRequest,
    GetIdentityContextResponse, ListEntityHistoryRequest, ListEntityHistoryResponse,
    ProposeEntityUpdateRequest, ProposeEntityUpdateResponse, RollbackEntityVersionRequest,
    RollbackEntityVersionResponse, UpdateTier,
};
use crate::ports::outbound::memory::identity_memory_store::IdentityMemoryStore;

#[derive(Clone)]
pub struct IdentityMemoryService {
    store: Arc<dyn IdentityMemoryStore>,
}

#[derive(Clone, Debug, Default)]
pub struct ProposeAndCommitRequest {
    pub proposal_request: ProposeEntityUpdateRequest,
    pub expected_version: i32,
    pub approver: Option<String>,
}

#[derive(Clone, Debug, Default)]
pub struct ProposeAndCommitResponse {
    pub proposed: ProposeEntityUpdateResponse,
    pub commits: Vec<CommitEntityUpdateResponse>,
}

impl IdentityMemoryService {
    pub fn new(store: Arc<dyn IdentityMemoryStore>) -> Self {
        Self { store }
    }

    pub async fn get_identity_context(
        &self,
        request: &GetIdentityContextRequest,
    ) -> Result<GetIdentityContextResponse> {
        self.store.get_identity_context(request).await
    }

    pub async fn propose_entity_update(
        &self,
        request: &ProposeEntityUpdateRequest,
    ) -> Result<ProposeEntityUpdateResponse> {
        self.store.propose_entity_update(request).await
    }

    pub async fn commit_entity_update(
        &self,
        request: &CommitEntityUpdateRequest,
    ) -> Result<CommitEntityUpdateResponse> {
        self.store.commit_entity_update(request).await
    }

    pub async fn list_entity_history(
        &self,
        request: &ListEntityHistoryRequest,
    ) -> Result<ListEntityHistoryResponse> {
        self.store.list_entity_history(request).await
    }

    pub async fn rollback_entity_version(
        &self,
        request: &RollbackEntityVersionRequest,
    ) -> Result<RollbackEntityVersionResponse> {
        self.store.rollback_entity_version(request).await
    }

    pub async fn propose_and_commit_autocommit(
        &self,
        request: &ProposeAndCommitRequest,
    ) -> Result<ProposeAndCommitResponse> {
        let proposed = self
            .store
            .propose_entity_update(&request.proposal_request)
            .await?;

        let mut expected_version = request.expected_version;
        let mut commits = Vec::new();

        for (idx, proposal_id) in proposed.proposal_ids.iter().enumerate() {
            let tier = proposed
                .tiers
                .get(idx)
                .copied()
                .unwrap_or(UpdateTier::ApprovalRequired);
            if !matches!(tier, UpdateTier::AutoCommit) {
                continue;
            }

            let commit = self
                .store
                .commit_entity_update(&CommitEntityUpdateRequest {
                    proposal_id: proposal_id.clone(),
                    expected_version,
                    approver: request.approver.clone(),
                })
                .await?;

            if commit.committed && let Some(new_version) = commit.new_version {
                expected_version = new_version;
            }

            commits.push(commit);
        }

        Ok(ProposeAndCommitResponse { proposed, commits })
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use chrono::Utc;
    use serde_json::json;

    use super::{IdentityMemoryService, ProposeAndCommitRequest};
    use crate::infrastructure::memory::in_memory_identity_memory_store::InMemoryIdentityMemoryStore;
    use crate::ports::outbound::memory::identity_memory_models::{
        EntityRef, IdentityEntityType, ProposeEntityUpdateRequest, RelationshipEntity,
        RelationshipStatus, UpdateSource,
    };

    #[tokio::test]
    async fn service_autocommits_only_auto_tier_proposals() {
        let store = Arc::new(InMemoryIdentityMemoryStore::default());
        store
            .upsert_relationship(RelationshipEntity {
                relationship_id: "rel-1".to_string(),
                source_entity_ref: EntityRef {
                    entity_type: "PersonaEntity".to_string(),
                    entity_id: "p1".to_string(),
                },
                target_entity_ref: EntityRef {
                    entity_type: "UserEntity".to_string(),
                    entity_id: "u1".to_string(),
                },
                relationship_kind: "assistant_user".to_string(),
                status: RelationshipStatus::Active,
                trust_level: 0.50,
                confidence: 0.80,
                strength_score: 0.80,
                recency_score: 0.60,
                autonomy_scope: Default::default(),
                approval_profile_id: None,
                interruption_policy: Default::default(),
                escalation_policy: Default::default(),
                policy_tags: vec![],
                provenance: UpdateSource::UserDirect,
                parent_relationship_id: None,
                governing_relationship_ids: vec![],
                derived_from_relationship_id: None,
                last_transition_reason: None,
                transition_receipt_id: None,
                version: 1,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            })
            .expect("seed relationship should succeed");

        let service = IdentityMemoryService::new(store);

        let result = service
            .propose_and_commit_autocommit(&ProposeAndCommitRequest {
                proposal_request: ProposeEntityUpdateRequest {
                    entity_type: IdentityEntityType::RelationshipEntity,
                    entity_id: "rel-1".to_string(),
                    patch: json!({
                        "recency_score": 0.9,
                        "autonomy_scope.allow": ["external_posting"]
                    }),
                    source: UpdateSource::ModelInferred,
                    confidence: 0.82,
                    reason: "mixed update".to_string(),
                    actor: "model".to_string(),
                    receipt_id: None,
                    expires_at: None,
                },
                expected_version: 1,
                approver: Some("owner".to_string()),
            })
            .await
            .expect("workflow should succeed");

        assert_eq!(result.proposed.proposal_ids.len(), 2);
        assert_eq!(result.commits.len(), 1);
        assert!(result.commits[0].committed);
    }
}