jacquardSnapshot

← snapshot

5947 bytes
//! The Unsettled → Settled typestate.
//!
//! Settlement is not a status field that anything can write. It is a type
//! parameter, and the only function in the workspace that produces the
//! `Settled` type takes an attestation. "A settled decision was attested by a
//! human" is therefore not checked at runtime — it is what the word
//! `Settled` *means* to the compiler.

use core::marker::PhantomData;

use jac_core::DecisionId;

use crate::attestation::Attestation;
use crate::decision::{Decision, DecisionError, decision_id};

mod sealed {
    /// Sealing keeps the state vocabulary closed: no downstream crate can
    /// add a third state that bypasses [`attest`](super::DecisionRecord::attest).
    pub trait Sealed {}
    impl Sealed for super::Unsettled {}
    impl Sealed for super::Settled {}
}

/// A settlement state. Sealed: exactly two states exist.
pub trait SettleState: sealed::Sealed {
    /// Label for narration and logs.
    const LABEL: &'static str;
}

/// Proposed, not yet attested. The gate blocks work in this state's scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Unsettled;

/// Attested by a human. The only path here is [`DecisionRecord::attest`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Settled;

impl SettleState for Unsettled {
    const LABEL: &'static str = "unsettled";
}
impl SettleState for Settled {
    const LABEL: &'static str = "settled";
}

/// A decision in a known settlement state.
///
/// ```compile_fail
/// use jac_decision::{DecisionRecord, Settled};
///
/// // There is no constructor for the Settled state — fields are private and
/// // `propose` exists only on DecisionRecord<Unsettled>. The single
/// // producing path is `attest`, which consumes an Attestation.
/// let record: DecisionRecord<Settled> = DecisionRecord {
///     _state: core::marker::PhantomData,
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecisionRecord<S: SettleState> {
    id: DecisionId,
    decision: Decision,
    /// `Some` if and only if `S = Settled`; privacy plus the single
    /// producing path keep that biconditional true.
    attestation: Option<Attestation>,
    _state: PhantomData<fn() -> S>,
}

impl<S: SettleState> DecisionRecord<S> {
    /// The decision's content address.
    #[must_use]
    pub const fn id(&self) -> DecisionId {
        self.id
    }

    /// The decision itself.
    #[must_use]
    pub const fn decision(&self) -> &Decision {
        &self.decision
    }

    /// The state's label, for narration.
    #[must_use]
    pub const fn state_label(&self) -> &'static str {
        S::LABEL
    }
}

impl DecisionRecord<Unsettled> {
    /// The only entry point: every decision starts unsettled.
    #[must_use]
    pub fn propose(decision: Decision) -> Self {
        Self {
            id: decision_id(&decision),
            decision,
            attestation: None,
            _state: PhantomData,
        }
    }

    /// The only path to `Settled`.
    ///
    /// Consumes the unsettled record and the attestation; checks the
    /// attestation actually names this decision.
    ///
    /// # Errors
    ///
    /// Returns [`DecisionError::WrongDecision`] if the attestation names a
    /// different decision id.
    pub fn attest(
        self,
        attestation: Attestation,
    ) -> Result<DecisionRecord<Settled>, DecisionError> {
        if attestation.decision() != self.id {
            return Err(DecisionError::WrongDecision);
        }
        Ok(DecisionRecord {
            id: self.id,
            decision: self.decision,
            attestation: Some(attestation),
            _state: PhantomData,
        })
    }
}

impl DecisionRecord<Settled> {
    /// The attestation that settled this decision.
    ///
    /// Infallible by construction: the only producing path stored one. The
    /// fallback exists solely to keep this panic-free without `unwrap`.
    #[must_use]
    pub const fn attestation(&self) -> Option<&Attestation> {
        self.attestation.as_ref()
    }
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "test assertions read better with unwrap"
)]
mod tests {
    use jac_core::{Author, DecisionId, Digest, HumanId, HumanIdentity, RepoPath, Timestamp};

    use super::*;
    use crate::attestation::Statement;
    use crate::decision::{DecisionScope, RationaleFamily};

    fn ada() -> HumanIdentity {
        HumanIdentity {
            id: HumanId::new(1).unwrap(),
            display_name: "Ada".to_owned(),
        }
    }

    fn proposal() -> DecisionRecord<Unsettled> {
        DecisionRecord::propose(Decision {
            title: "auth retry fails closed".to_owned(),
            rationale: "drafted by the interviewer from the diff".to_owned(),
            families: vec![RationaleFamily::Constraints],
            proposed_by: Author::Human(HumanId::new(1).unwrap()),
            scope: DecisionScope {
                path_prefixes: vec![RepoPath::parse("src/auth").unwrap()],
            },
            at: Timestamp::from_millis(10),
        })
    }

    #[test]
    fn attest_settles() {
        let record = proposal();
        let attestation = Attestation::new(
            &ada(),
            record.id(),
            Statement::new("Walked the 401 path by hand; it fails closed.").unwrap(),
            Timestamp::from_millis(20),
        );
        let settled = record.attest(attestation).unwrap();
        assert_eq!(settled.state_label(), "settled");
        assert!(settled.attestation().is_some());
    }

    #[test]
    fn attesting_the_wrong_decision_is_rejected() {
        let record = proposal();
        let stray = Attestation::new(
            &ada(),
            DecisionId::from_digest(Digest::of(b"some other decision")),
            Statement::new("looks fine").unwrap(),
            Timestamp::from_millis(20),
        );
        assert_eq!(record.attest(stray), Err(DecisionError::WrongDecision));
    }
}