jacquardSnapshot

← snapshot

5813 bytes
//! Decisions: list, detail, propose, attest.

use core::str::FromStr as _;

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use jac_core::HumanId;
use jac_decision::Statement;
use jac_telemetry::Envelope;
use serde::Deserialize;
use serde_json::{Value, json};

use crate::dto::{self, AttestationDto, DecisionDto, ProposeDecisionRequest};
use crate::error::ApiError;
use crate::routes::{with_repo, with_repo_mut};
use crate::state::SharedState;

#[derive(Debug, Deserialize)]
pub(crate) struct ListQuery {
    /// `unsettled`, `settled`, or absent for all.
    #[serde(default)]
    pub state: Option<String>,
}

/// `GET /api/repos/{slug}/decisions?state=`.
pub(crate) async fn list(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Query(query): Query<ListQuery>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let want = query.state.as_deref();
        let mut decisions: Vec<DecisionDto> = Vec::new();
        if want.is_none() || want == Some("unsettled") {
            decisions.extend(
                entry
                    .repo
                    .pending_decisions()
                    .map(|r| DecisionDto::of(r, entry)),
            );
        }
        if want.is_none() || want == Some("settled") {
            decisions.extend(entry.repo.settled_decisions().map(|r| {
                let mut d = DecisionDto::of(r, entry);
                d.attestation = r.attestation().map(|a| AttestationDto::of(a, entry));
                d
            }));
        }
        if let Some(other) = want
            && other != "unsettled"
            && other != "settled"
        {
            return Err(ApiError::invalid(format!(
                "unknown state filter `{other}` (unsettled|settled)"
            )));
        }
        // Newest first, like the log.
        decisions.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
        Ok(Json(json!({ "decisions": decisions })))
    })
}

/// `GET /api/repos/{slug}/decisions/{hex}`.
pub(crate) async fn detail(
    State(state): State<SharedState>,
    Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<DecisionDto>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let id = dto::parse_decision_id(&hex)?;
        if let Some(record) = entry.repo.pending_decisions().find(|r| r.id() == id) {
            return Ok(Json(DecisionDto::of(record, entry)));
        }
        if let Some(record) = entry.repo.settled_decision(id) {
            let mut d = DecisionDto::of(record, entry);
            d.attestation = record.attestation().map(|a| AttestationDto::of(a, entry));
            return Ok(Json(d));
        }
        Err(ApiError::not_found("no decision with that id"))
    })
}

/// `POST /api/repos/{slug}/decisions` — propose. Anyone may, including on
/// behalf of an agent; `proposed_by` says so honestly. It lands unsettled.
pub(crate) async fn propose(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Json(req): Json<ProposeDecisionRequest>,
) -> Result<(StatusCode, Json<DecisionDto>), ApiError> {
    let dto = with_repo_mut(&state, &slug, |entry| {
        let at = entry.repo.now();
        let decision = req.into_decision(entry, at)?;
        let id = entry.repo.propose_decision(decision);
        entry
            .repo
            .pending_decisions()
            .find(|r| r.id() == id)
            .map(|r| DecisionDto::of(r, entry))
            .ok_or_else(|| ApiError::internal("proposed decision vanished"))
    })?;
    state.telemetry.emit(
        Envelope::event("jac.decision.proposed", dto.at)
            .attr("repo", slug)
            .attr("families", dto.families.join(","))
            .attr("proposer_kind", dto.proposed_by.kind),
    );
    Ok((StatusCode::CREATED, Json(dto)))
}

/// An attestation request: who signs, and their verbatim words.
#[derive(Debug, Deserialize)]
pub(crate) struct AttestRequest {
    /// `hum-…` — only a human id fits. An agent id is refused here at parse
    /// time, and even a forged request could not mint an attestation: no
    /// constructor accepts an agent.
    pub by: String,
    pub statement: String,
}

/// `POST /api/repos/{slug}/decisions/{hex}/attest`.
pub(crate) async fn attest(
    State(state): State<SharedState>,
    Path((slug, hex)): Path<(String, String)>,
    Json(req): Json<AttestRequest>,
) -> Result<Json<DecisionDto>, ApiError> {
    let statement_chars = req.statement.trim().chars().count();
    let dto = with_repo_mut(&state, &slug, |entry| {
        let id = dto::parse_decision_id(&hex)?;
        let human_id = HumanId::from_str(&req.by).map_err(|e| {
            ApiError::invalid(format!(
                "`by` must be a hum- id — only a human can attest: {e}"
            ))
        })?;
        let identity = entry.humans.get(&human_id).cloned().ok_or_else(|| {
            ApiError::invalid(format!("human {human_id} is not registered in this repo"))
        })?;
        let statement = Statement::new(&req.statement)?;
        entry.repo.attest(id, &identity, statement)?;
        let record = entry
            .repo
            .settled_decision(id)
            .ok_or_else(|| ApiError::internal("attested decision vanished"))?;
        let mut d = DecisionDto::of(record, entry);
        d.attestation = record.attestation().map(|a| AttestationDto::of(a, entry));
        Ok(d)
    })?;
    let attested_at = dto.attestation.as_ref().map_or(dto.at, |a| a.at);
    state.telemetry.emit(
        Envelope::event("jac.decision.attested", attested_at)
            .attr("repo", slug)
            .measurement(
                "statement_chars",
                f64::from(u32::try_from(statement_chars).unwrap_or(u32::MAX)),
            ),
    );
    Ok(Json(dto))
}