tandem-server 0.6.9

HTTP server for Tandem engine APIs
use serde_json::{json, Value};

use crate::audit::append_protected_audit_event;
use crate::automation_v2::governance::GovernanceApprovalStatus;
use crate::{now_ms, AppState};

use super::governance::approval_receipt_matches_tenant;

fn action_gate_field<'a>(context: &'a Value, field: &str) -> Option<&'a Value> {
    context.get("action_gate")?.get(field)
}

pub(super) fn is_action_gate_context(context: &Value) -> bool {
    action_gate_field(context, "action_hash")
        .and_then(Value::as_str)
        .is_some()
}

pub(super) fn same_action_gate_scope(left: &Value, right: &Value) -> bool {
    ["action_hash", "session_id", "message_id", "run_id"]
        .into_iter()
        .all(|field| action_gate_field(left, field) == action_gate_field(right, field))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActionGateApprovalState {
    Pending,
    ApprovedAndConsumed,
    Denied,
}

impl AppState {
    pub(crate) async fn consume_action_gate_approval(
        &self,
        approval_id: &str,
        tenant_context: &tandem_types::TenantContext,
    ) -> anyhow::Result<ActionGateApprovalState> {
        let now = now_ms();
        let resolution = {
            let mut guard = self.automation_governance.write().await;
            let request = guard
                .approvals
                .get_mut(approval_id)
                .filter(|request| approval_receipt_matches_tenant(request, tenant_context))
                .ok_or_else(|| anyhow::anyhow!("action-gate approval request not found"))?;

            match request.status {
                GovernanceApprovalStatus::Pending => ActionGateApprovalState::Pending,
                GovernanceApprovalStatus::Denied | GovernanceApprovalStatus::Expired => {
                    ActionGateApprovalState::Denied
                }
                GovernanceApprovalStatus::Approved => {
                    if now >= request.expires_at_ms
                        || request
                            .context
                            .pointer("/action_gate/consumed_at_ms")
                            .is_some()
                    {
                        ActionGateApprovalState::Denied
                    } else {
                        request.context["action_gate"]["consumed_at_ms"] = json!(now);
                        request.updated_at_ms = now;
                        guard.updated_at_ms = now;
                        ActionGateApprovalState::ApprovedAndConsumed
                    }
                }
            }
        };

        if resolution == ActionGateApprovalState::ApprovedAndConsumed {
            self.persist_automation_governance().await?;
            append_protected_audit_event(
                self,
                "automation.governance.approval.consumed",
                tenant_context,
                tenant_context.actor_id.clone(),
                json!({ "approvalID": approval_id }),
            )
            .await?;
        }
        Ok(resolution)
    }
}