← 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 }
}
}