jacquardSnapshot

← snapshot

13572 bytes
//! Wire types and conversions.
//!
//! Conversions live here, in the surface crate only. No serde is added to
//! the content crates: their canonical encodings are the identity-bearing
//! formats, and this JSON must never be mistaken for one.

use core::str::FromStr as _;

use jac_core::{AgentId, Author, DecisionId, Digest, HumanId, Provenance, RepoPath, SnapshotId};
use jac_decision::{
    Attestation, Decision, DecisionRecord, DecisionScope, RationaleFamily, SettleState,
};
use jac_gate::{BlockReason, Verdict};
use jac_object::Snapshot;
use jac_sketch::MinHashSketch;
use serde::{Deserialize, Serialize};

use crate::error::ApiError;
use crate::state::RepoEntry;

// ---------------------------------------------------------------------------
// Id parsing helpers. Object ids travel as bare 64-hex digests (the serde
// form); scalar ids as their prefixed rendering (`hum-…`).
// ---------------------------------------------------------------------------

pub(crate) fn parse_snapshot_id(hex: &str) -> Result<SnapshotId, ApiError> {
    Digest::from_str(hex)
        .map(SnapshotId::from_digest)
        .map_err(|e| ApiError::invalid(format!("malformed snapshot id: {e}")))
}

pub(crate) fn parse_decision_id(hex: &str) -> Result<DecisionId, ApiError> {
    Digest::from_str(hex)
        .map(DecisionId::from_digest)
        .map_err(|e| ApiError::invalid(format!("malformed decision id: {e}")))
}

pub(crate) fn parse_blob_id(hex: &str) -> Result<jac_core::BlobId, ApiError> {
    Digest::from_str(hex)
        .map(jac_core::BlobId::from_digest)
        .map_err(|e| ApiError::invalid(format!("malformed blob id: {e}")))
}

pub(crate) fn parse_repo_path(s: &str) -> Result<RepoPath, ApiError> {
    RepoPath::parse(s).map_err(|e| ApiError::invalid(format!("invalid path `{s}`: {e}")))
}

pub(crate) fn parse_provenance(s: &str) -> Result<Provenance, ApiError> {
    Provenance::parse_label(s)
        .ok_or_else(|| ApiError::invalid(format!("unknown provenance `{s}` (human|agent|mixed)")))
}

pub(crate) fn parse_family(label: &str) -> Result<RationaleFamily, ApiError> {
    const ALL: [RationaleFamily; 5] = [
        RationaleFamily::Alternatives,
        RationaleFamily::Constraints,
        RationaleFamily::MissedAbstraction,
        RationaleFamily::DeferredWork,
        RationaleFamily::ConfidenceRisk,
    ];
    ALL.into_iter()
        .find(|f| f.label() == label)
        .ok_or_else(|| ApiError::invalid(format!("unknown rationale family `{label}`")))
}

/// Bare hex form of an object id, as the serde layer emits it.
fn hex(digest: Digest) -> String {
    digest.to_hex().as_str().to_owned()
}

// ---------------------------------------------------------------------------
// Actors
// ---------------------------------------------------------------------------

/// An actor named by a write request. Resolved against the repo's registered
/// identities — refused, never authenticated.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ActorRef {
    /// `human` or `agent`.
    pub kind: String,
    /// `hum-…` / `agt-…`.
    pub id: String,
}

impl ActorRef {
    pub(crate) fn resolve(&self, entry: &RepoEntry) -> Result<Author, ApiError> {
        match self.kind.as_str() {
            "human" => {
                let id = HumanId::from_str(&self.id)
                    .map_err(|e| ApiError::invalid(format!("bad human id: {e}")))?;
                if entry.humans.contains_key(&id) {
                    Ok(Author::Human(id))
                } else {
                    Err(ApiError::invalid(format!(
                        "human {id} is not registered in this repo"
                    )))
                }
            }
            "agent" => {
                let id = AgentId::from_str(&self.id)
                    .map_err(|e| ApiError::invalid(format!("bad agent id: {e}")))?;
                if entry.agents.contains_key(&id) {
                    Ok(Author::Agent(id))
                } else {
                    Err(ApiError::invalid(format!(
                        "agent {id} is not registered in this repo"
                    )))
                }
            }
            other => Err(ApiError::invalid(format!(
                "unknown actor kind `{other}` (human|agent)"
            ))),
        }
    }
}

/// An actor in a response, with its display name when registered.
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ActorView {
    pub kind: &'static str,
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub display_name: Option<String>,
}

impl ActorView {
    pub(crate) fn of(author: Author, entry: &RepoEntry) -> Self {
        let (kind, id) = match author {
            Author::Human(id) => ("human", id.to_string()),
            Author::Agent(id) => ("agent", id.to_string()),
            _ => ("unknown", String::new()),
        };
        Self {
            kind,
            id,
            display_name: entry.display_name(author),
        }
    }
}

// ---------------------------------------------------------------------------
// Snapshots, refs, trees
// ---------------------------------------------------------------------------

/// A snapshot, with everything the DAG and the log need.
#[derive(Debug, Serialize)]
pub(crate) struct SnapshotDto {
    pub id: String,
    pub short: String,
    pub tree: String,
    pub parents: Vec<String>,
    pub author: ActorView,
    pub provenance: &'static str,
    pub message: String,
    pub at: i64,
}

impl SnapshotDto {
    pub(crate) fn of(id: SnapshotId, snapshot: &Snapshot, entry: &RepoEntry) -> Self {
        Self {
            id: hex(id.digest()),
            short: id.short(),
            tree: hex(snapshot.tree.digest()),
            parents: snapshot.parents.iter().map(|p| hex(p.digest())).collect(),
            author: ActorView::of(snapshot.author, entry),
            provenance: snapshot.provenance.label(),
            message: snapshot.message.clone(),
            at: snapshot.at.as_millis(),
        }
    }
}

/// A ref and where it points.
#[derive(Debug, Serialize)]
pub(crate) struct RefDto {
    pub name: String,
    pub head: String,
}

/// One entry in a directory listing.
#[derive(Debug, Serialize)]
pub(crate) struct TreeEntryDto {
    pub name: String,
    pub kind: &'static str,
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size: Option<usize>,
}

/// One file in a recursive listing.
#[derive(Debug, Serialize)]
pub(crate) struct FileEntryDto {
    pub path: String,
    pub blob: String,
    pub size: usize,
}

// ---------------------------------------------------------------------------
// Decisions & attestations
// ---------------------------------------------------------------------------

/// An attestation: the human's verbatim words, a separate object.
#[derive(Debug, Serialize)]
pub(crate) struct AttestationDto {
    pub id: String,
    pub decision: String,
    pub attestor: ActorView,
    pub statement: String,
    pub at: i64,
}

impl AttestationDto {
    pub(crate) fn of(attestation: &Attestation, entry: &RepoEntry) -> Self {
        Self {
            id: hex(attestation.id().digest()),
            decision: hex(attestation.decision().digest()),
            attestor: ActorView::of(Author::Human(attestation.attestor()), entry),
            statement: attestation.statement().as_str().to_owned(),
            at: attestation.at().as_millis(),
        }
    }
}

/// A decision, with its settlement state and — when settled — the
/// attestation that settled it.
#[derive(Debug, Serialize)]
pub(crate) struct DecisionDto {
    pub id: String,
    pub short: String,
    pub title: String,
    pub rationale: String,
    pub families: Vec<&'static str>,
    pub proposed_by: ActorView,
    pub scope: Vec<String>,
    pub at: i64,
    pub state: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attestation: Option<AttestationDto>,
}

impl DecisionDto {
    pub(crate) fn of<S: SettleState>(record: &DecisionRecord<S>, entry: &RepoEntry) -> Self {
        let decision = record.decision();
        Self {
            id: hex(record.id().digest()),
            short: record.id().short(),
            title: decision.title.clone(),
            rationale: decision.rationale.clone(),
            families: decision.families.iter().map(|f| f.label()).collect(),
            proposed_by: ActorView::of(decision.proposed_by, entry),
            scope: decision
                .scope
                .path_prefixes
                .iter()
                .map(|p| p.as_str().to_owned())
                .collect(),
            at: decision.at.as_millis(),
            state: record.state_label(),
            attestation: None,
        }
    }
}

/// A propose-decision request body.
#[derive(Debug, Deserialize)]
pub(crate) struct ProposeDecisionRequest {
    pub title: String,
    pub rationale: String,
    pub families: Vec<String>,
    pub actor: ActorRef,
    pub scope: Vec<String>,
}

impl ProposeDecisionRequest {
    pub(crate) fn into_decision(
        self,
        entry: &RepoEntry,
        at: jac_core::Timestamp,
    ) -> Result<Decision, ApiError> {
        let proposed_by = self.actor.resolve(entry)?;
        let families = self
            .families
            .iter()
            .map(|f| parse_family(f))
            .collect::<Result<Vec<_>, _>>()?;
        let path_prefixes = self
            .scope
            .iter()
            .map(|p| parse_repo_path(p))
            .collect::<Result<Vec<_>, _>>()?;
        Ok(Decision {
            title: self.title,
            rationale: self.rationale,
            families,
            proposed_by,
            scope: DecisionScope { path_prefixes },
            at,
        })
    }
}

// ---------------------------------------------------------------------------
// Verdicts
// ---------------------------------------------------------------------------

/// The gate's answer, on the wire.
///
/// `ungoverned` is computed here, server-side, so no frontend can forget
/// that an admitted-with-zero is not a verification of anything.
#[derive(Debug, Serialize)]
#[serde(tag = "verdict", rename_all = "lowercase")]
pub(crate) enum VerdictDto {
    /// Every governing decision in the blast radius is settled.
    Admitted {
        decisions_checked: usize,
        ungoverned: bool,
        ref_moved: bool,
        #[serde(skip_serializing_if = "Option::is_none")]
        into_head: Option<String>,
    },
    /// The change may not promote. The work parks; nobody is punished.
    Blocked {
        reason: &'static str,
        unsettled: Vec<String>,
        ref_moved: bool,
    },
}

impl VerdictDto {
    /// Converts a verdict. A variant this surface does not recognise renders
    /// as blocked: a wire format must not admit what the gate did not.
    pub(crate) fn of(verdict: &Verdict, ref_moved: bool, into_head: Option<String>) -> Self {
        match verdict {
            Verdict::Admitted { decisions_checked } => Self::Admitted {
                decisions_checked: *decisions_checked,
                ungoverned: *decisions_checked == 0,
                ref_moved,
                into_head,
            },
            Verdict::Blocked { unsettled, reason } => Self::Blocked {
                reason: block_reason_label(*reason),
                unsettled: unsettled.iter().map(|d| hex(d.digest())).collect(),
                ref_moved: false,
            },
            _ => Self::Blocked {
                reason: "unknown",
                unsettled: Vec::new(),
                ref_moved: false,
            },
        }
    }

    /// `(label, decisions_checked, ungoverned, reason)`, for the telemetry
    /// event — a small, stable summary rather than re-deriving the shape at
    /// each call site.
    pub(crate) const fn telemetry_fields(
        &self,
    ) -> (&'static str, usize, bool, Option<&'static str>) {
        match self {
            Self::Admitted {
                decisions_checked,
                ungoverned,
                ..
            } => ("admitted", *decisions_checked, *ungoverned, None),
            Self::Blocked { reason, .. } => ("blocked", 0, false, Some(*reason)),
        }
    }
}

const fn block_reason_label(reason: BlockReason) -> &'static str {
    match reason {
        BlockReason::NotEvaluated => "not-evaluated",
        BlockReason::UnsettledDecisions => "unsettled-decisions",
        BlockReason::LedgerInconsistent => "ledger-inconsistent",
        _ => "unknown",
    }
}

// ---------------------------------------------------------------------------
// Sketches
// ---------------------------------------------------------------------------

/// A sketch on the wire. Lanes are strings: they are `u64` minima, and JSON
/// numbers lose precision past 2^53 in every JavaScript consumer.
#[derive(Debug, Serialize)]
pub(crate) struct SketchDto {
    pub kind: &'static str,
    pub lanes: Vec<String>,
}

impl SketchDto {
    pub(crate) fn of(kind: &'static str, sketch: &MinHashSketch) -> Self {
        // The sketch deliberately exposes no lane accessor; its serde form
        // (a tuple of 64 u64s) is the sanctioned reading.
        let lanes = serde_json::to_value(sketch)
            .ok()
            .and_then(|v| v.as_array().cloned())
            .map(|arr| {
                arr.iter()
                    .map(|n| n.as_u64().unwrap_or_default().to_string())
                    .collect()
            })
            .unwrap_or_default();
        Self { kind, lanes }
    }
}