//! Attestations: the human's own words, sealed to a decision.
use jac_core::{
AttestationId, DecisionId, DigestHasher, HumanId, HumanIdentity, ObjectTag, Timestamp,
};
use crate::decision::DecisionError;
/// A short written account only understanding can produce.
///
/// Validated: non-empty after trimming, at most 2000 characters. Long enough
/// for a few honest sentences; short enough that writing one is answering a
/// question, not filing a report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Statement(String);
impl Statement {
/// Validates and wraps a statement.
///
/// # Errors
///
/// Returns [`DecisionError::InvalidStatement`] if the text is empty after
/// trimming or longer than 2000 characters.
pub fn new(text: &str) -> Result<Self, DecisionError> {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.chars().count() > 2000 {
return Err(DecisionError::InvalidStatement);
}
Ok(Self(trimmed.to_owned()))
}
/// The statement text.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A human's attestation of a decision.
///
/// This is the *verbatim* record — what the person actually said, distinct
/// from any AI-synthesized rationale on the decision itself. The two are
/// separate objects with separate provenance precisely so they can never be
/// conflated.
///
/// # The invariant
///
/// The only constructor takes [`&HumanIdentity`](HumanIdentity). There is no
/// `From<AgentIdentity>`, no `TryFrom`, and no other path — an agent cannot
/// attest, as a fact about the type system rather than a policy:
///
/// ```compile_fail
/// use jac_core::{AgentId, AgentIdentity, DecisionId, Digest, Timestamp};
/// use jac_decision::{Attestation, Statement};
///
/// let bot = AgentIdentity {
/// id: AgentId::new(1).unwrap(),
/// model: "loom-bot".into(),
/// };
/// let statement = Statement::new("I read it and it holds.").unwrap();
///
/// // No such signature exists. An agent's identity does not fit where a
/// // human's is required, and there is no conversion between them.
/// let a = Attestation::new(
/// &bot,
/// DecisionId::from_digest(Digest::ZERO),
/// statement,
/// Timestamp::EPOCH,
/// );
/// ```
///
/// Honest scope: in this milestone a [`HumanIdentity`] is constructible by
/// any in-process caller, so the invariant is type-level, not
/// authentication. Signing is a named unblock condition in the architecture.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attestation {
decision: DecisionId,
attestor: HumanId,
statement: Statement,
at: Timestamp,
}
impl Attestation {
/// Mints an attestation. Only a [`HumanIdentity`] fits.
#[must_use]
pub const fn new(
by: &HumanIdentity,
decision: DecisionId,
statement: Statement,
at: Timestamp,
) -> Self {
Self {
decision,
attestor: by.id,
statement,
at,
}
}
/// The decision this attestation settles.
#[must_use]
pub const fn decision(&self) -> DecisionId {
self.decision
}
/// Who attested.
#[must_use]
pub const fn attestor(&self) -> HumanId {
self.attestor
}
/// The verbatim statement.
#[must_use]
pub const fn statement(&self) -> &Statement {
&self.statement
}
/// When it was attested.
#[must_use]
pub const fn at(&self) -> Timestamp {
self.at
}
/// The attestation's content address.
#[must_use]
pub fn id(&self) -> AttestationId {
let mut h = DigestHasher::new();
h.tag(ObjectTag::Attestation.as_byte());
h.digest(self.decision.digest());
h.u64(self.attestor.get());
h.field(self.statement.0.as_bytes());
h.i64(self.at.as_millis());
AttestationId::from_digest(h.finish())
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "test assertions read better with unwrap"
)]
mod tests {
use jac_core::{Digest, HumanId};
use super::*;
#[test]
fn statements_are_validated() {
assert!(Statement::new(" ").is_err());
assert!(Statement::new(&"x".repeat(2001)).is_err());
assert!(Statement::new("I walked the retry path; it fails closed.").is_ok());
}
#[test]
fn attestation_id_binds_all_fields() {
let ada = HumanIdentity {
id: HumanId::new(1).unwrap(),
display_name: "Ada".to_owned(),
};
let dec = DecisionId::from_digest(Digest::of(b"decision"));
let a = Attestation::new(
&ada,
dec,
Statement::new("first reading").unwrap(),
Timestamp::from_millis(1),
);
let b = Attestation::new(
&ada,
dec,
Statement::new("second reading").unwrap(),
Timestamp::from_millis(1),
);
assert_ne!(a.id(), b.id());
}
}