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::memos as repo;
use crate::domain::memos::Memo;
use crate::error::{AppError, AppResult};

use super::documents::normalize_limit;
use super::drift;

/// Allowed memo lifecycle states.
pub const MEMO_STATUSES: [&str; 2] = ["draft", "final"];

pub async fn create_memo(
    pool: &PgPool,
    memo_type: &str,
    title: &str,
    body_markdown: &str,
    status: Option<&str>,
) -> AppResult<Memo> {
    let memo_type = memo_type.trim();
    let title = title.trim();
    let status = status.unwrap_or("draft").trim();
    if memo_type.is_empty() {
        return Err(AppError::Validation("memo_type must not be empty".into()));
    }
    if title.is_empty() {
        return Err(AppError::Validation("title must not be empty".into()));
    }
    if body_markdown.trim().is_empty() {
        return Err(AppError::Validation("body_markdown must not be empty".into()));
    }
    validate_status(status)?;

    Ok(repo::create(
        pool,
        repo::NewMemo {
            title,
            memo_type,
            body_markdown,
            status,
        },
    )
    .await?)
}

/// Update a memo. Any field left as `None` keeps its current value, so this
/// doubles as "edit body", "rename", and "mark as final".
pub async fn update_memo(
    pool: &PgPool,
    id: Uuid,
    title: Option<&str>,
    memo_type: Option<&str>,
    body_markdown: Option<&str>,
    status: Option<&str>,
) -> AppResult<Memo> {
    let current = get_memo(pool, id).await?;

    let title = title.map(str::trim).unwrap_or(&current.title);
    let memo_type = memo_type.map(str::trim).unwrap_or(&current.memo_type);
    let body_markdown = body_markdown
        .map(str::trim)
        .unwrap_or(&current.body_markdown);
    let status = status.map(str::trim).unwrap_or(&current.status);

    if title.is_empty() {
        return Err(AppError::Validation("title must not be empty".into()));
    }
    if memo_type.is_empty() {
        return Err(AppError::Validation("memo_type must not be empty".into()));
    }
    if body_markdown.is_empty() {
        return Err(AppError::Validation("body_markdown must not be empty".into()));
    }
    validate_status(status)?;

    repo::update(
        pool,
        id,
        repo::MemoUpdate {
            title,
            memo_type,
            body_markdown,
            status,
        },
    )
    .await?
    .ok_or_else(|| AppError::NotFound(format!("memo {id} not found")))
}

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

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

/// Create a draft memo seeded from a drift signal. The body is a structured
/// placeholder the agent (or user) can later refine and then mark `final`.
pub async fn create_memo_from_drift(
    pool: &PgPool,
    drift_signal_id: Uuid,
) -> AppResult<Memo> {
    let signal = drift::get_drift_signal(pool, drift_signal_id).await?;

    let title = format!("Drift memo: {}", signal.summary);
    let body = format!(
        "# {title}\n\n\
         ## Summary\n{summary}\n\n\
         ## Drift signal\n- Type: {drift_type}\n- Severity: {severity}\n- Target: {target_type} ({target_id})\n\n\
         ## Explanation\n{explanation}\n\n\
         ## Relevant decision\n_TODO: describe the affected decision._\n\n\
         ## Recommended next actions\n_TODO: list concrete actions._\n\n\
         ## Unknowns\n_TODO: list missing information or open questions._\n",
        title = title,
        summary = signal.summary,
        drift_type = signal.drift_type,
        severity = signal.severity,
        target_type = signal.target_entity_type,
        target_id = signal.target_entity_id,
        explanation = signal.explanation,
    );

    create_memo(pool, "drift_memo", &title, &body, Some("draft")).await
}

fn validate_status(status: &str) -> AppResult<()> {
    if MEMO_STATUSES.contains(&status) {
        Ok(())
    } else {
        Err(AppError::Validation(format!(
            "invalid memo status `{status}`; expected one of: {}",
            MEMO_STATUSES.join(", ")
        )))
    }
}