jacquardSnapshot

← snapshot

5020 bytes
//! 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());
    }
}