decision_cockpit 0.1.0

Layer — product decision memory with MCP tools and an embedded review dashboard
Documentation
use sqlx::PgPool;
use uuid::Uuid;

use crate::db::repositories::{
    actions as actions_repo, assumptions as assumptions_repo, decisions as decisions_repo,
    drift as repo, evidence as evidence_repo,
};
use crate::domain::drift::DriftSignal;
use crate::domain::{DriftType, EntityType, Severity};
use crate::error::{AppError, AppResult};

use super::documents::normalize_limit;

pub struct NewDriftSignalInput {
    pub drift_type: DriftType,
    pub target_entity_id: Uuid,
    pub target_entity_type: EntityType,
    pub summary: String,
    pub severity: Severity,
    pub explanation: String,
}

pub async fn create_drift_signal(
    pool: &PgPool,
    input: NewDriftSignalInput,
) -> AppResult<DriftSignal> {
    if input.summary.trim().is_empty() {
        return Err(AppError::Validation("summary must not be empty".into()));
    }
    if input.explanation.trim().is_empty() {
        return Err(AppError::Validation("explanation must not be empty".into()));
    }

    ensure_target_exists(pool, input.target_entity_type, input.target_entity_id).await?;

    Ok(repo::create(
        pool,
        crate::db::repositories::drift::NewDriftSignal {
            drift_type: input.drift_type.as_str(),
            target_entity_id: input.target_entity_id,
            target_entity_type: input.target_entity_type.as_str(),
            summary: input.summary.trim(),
            severity: input.severity.as_str(),
            explanation: input.explanation.trim(),
        },
    )
    .await?)
}

pub async fn list_drift_signals(
    pool: &PgPool,
    status: Option<&str>,
    limit: Option<i64>,
) -> AppResult<Vec<DriftSignal>> {
    Ok(repo::list(pool, status, normalize_limit(limit)).await?)
}

pub async fn list_open_drift_signals(
    pool: &PgPool,
    limit: Option<i64>,
) -> AppResult<Vec<DriftSignal>> {
    Ok(repo::list(pool, Some("open"), normalize_limit(limit)).await?)
}

pub async fn get_drift_signal(pool: &PgPool, id: Uuid) -> AppResult<DriftSignal> {
    repo::get(pool, id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("drift signal {id} not found")))
}

/// Acknowledge a drift signal as valid. If it targets a decision, that decision
/// is flipped to `under_review` so the human-owned status reflects the concern.
pub async fn accept_drift_signal(pool: &PgPool, id: Uuid) -> AppResult<DriftSignal> {
    let signal = set_status(pool, id, "accepted").await?;
    if signal.target_entity_type == EntityType::Decision.as_str() {
        // Best-effort: don't fail acknowledgement if the decision is gone.
        let _ = decisions_repo::update_status(pool, signal.target_entity_id, "under_review").await;
    }
    Ok(signal)
}

pub async fn dismiss_drift_signal(pool: &PgPool, id: Uuid) -> AppResult<DriftSignal> {
    set_status(pool, id, "dismissed").await
}

async fn set_status(pool: &PgPool, id: Uuid, status: &str) -> AppResult<DriftSignal> {
    repo::update_status(pool, id, status)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("drift signal {id} not found")))
}

/// Best-effort check that the targeted canonical entity exists.
async fn ensure_target_exists(
    pool: &PgPool,
    entity_type: EntityType,
    entity_id: Uuid,
) -> AppResult<()> {
    let exists = match entity_type {
        EntityType::Decision => decisions_repo::get(pool, entity_id).await?.is_some(),
        EntityType::Assumption => assumptions_repo::get(pool, entity_id).await?.is_some(),
        EntityType::Action => actions_repo::get(pool, entity_id).await?.is_some(),
        EntityType::Evidence => evidence_repo::get(pool, entity_id).await?.is_some(),
        // Drift signals can reference goals/memos that we do not strictly model
        // as standalone canonical tables yet; skip the existence check.
        EntityType::DriftSignal | EntityType::Memo => true,
    };

    if exists {
        Ok(())
    } else {
        Err(AppError::NotFound(format!(
            "{} {entity_id} not found",
            entity_type.as_str()
        )))
    }
}