jacquardSnapshot

← snapshot

6385 bytes
//! The decision object.

use jac_core::{Author, DecisionId, DigestHasher, ObjectTag, RepoPath, Timestamp};
use thiserror::Error;

/// Which family of design knowledge a decision records.
///
/// The taxonomy of an interview-at-merge-time workflow: an AI interviewer
/// asks questions from these families, and the decision it drafts is tagged
/// with the families its rationale covers. `#[non_exhaustive]` because the
/// taxonomy will grow.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
#[repr(u8)]
pub enum RationaleFamily {
    /// Alternatives considered and what ruled them out.
    Alternatives = 1,
    /// The constraint that forced this shape.
    Constraints = 2,
    /// An abstraction within reach that was skipped — deliberately or not.
    MissedAbstraction = 3,
    /// Known debt: what another week would have changed.
    DeferredWork = 4,
    /// The part the author is least sure will hold up.
    ConfidenceRisk = 5,
}

impl RationaleFamily {
    /// The byte hashed into the decision's identity.
    #[must_use]
    pub const fn as_byte(self) -> u8 {
        self as u8
    }

    /// Stable lowercase label for narration and trailers.
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Alternatives => "alternatives",
            Self::Constraints => "constraints",
            Self::MissedAbstraction => "missed-abstraction",
            Self::DeferredWork => "deferred-work",
            Self::ConfidenceRisk => "confidence-risk",
        }
    }
}

/// What part of the repository a decision governs.
///
/// Milestone-1 scope is segment-wise path prefixes (see
/// [`RepoPath::is_under`]); globs and symbol-level scopes are later work. A
/// decision with an empty scope governs nothing, which the gate treats
/// honestly: it will never block on it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DecisionScope {
    /// Paths at or under any of these prefixes are governed.
    pub path_prefixes: Vec<RepoPath>,
}

impl DecisionScope {
    /// Whether `path` falls under this scope.
    #[must_use]
    pub fn governs(&self, path: &RepoPath) -> bool {
        self.path_prefixes.iter().any(|p| path.is_under(p))
    }
}

/// A design decision, proposed but not yet settled.
///
/// Anyone may propose — including an agent. The rationale is often
/// AI-synthesized (from an interview, a diff analysis, a duplication
/// detector); what it can never be is *settled* by the machine that drafted
/// it. Settlement is the human's move, and it is a different object with
/// different provenance: see [`crate::Attestation`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Decision {
    /// Short imperative title, ADR-style.
    pub title: String,
    /// The reasoning. May be agent-drafted; the decision's `proposed_by`
    /// says so honestly.
    pub rationale: String,
    /// Which families of design knowledge the rationale covers.
    pub families: Vec<RationaleFamily>,
    /// Who proposed it — a person or an agent, stated plainly.
    pub proposed_by: Author,
    /// What part of the repository it governs.
    pub scope: DecisionScope,
    /// When it was proposed, from an injected clock.
    pub at: Timestamp,
}

/// Computes a decision's content address.
///
/// Everything is hashed, scope and families included: broadening a decision's
/// scope after people attested to the narrow one is a different decision.
#[must_use]
pub fn decision_id(decision: &Decision) -> DecisionId {
    let mut h = DigestHasher::new();
    h.tag(ObjectTag::Decision.as_byte());
    h.field(decision.title.as_bytes());
    h.field(decision.rationale.as_bytes());
    h.u64(u64::try_from(decision.families.len()).unwrap_or(u64::MAX));
    for family in &decision.families {
        h.u64(u64::from(family.as_byte()));
    }
    match decision.proposed_by {
        Author::Human(id) => h.u64(0).u64(id.get()),
        Author::Agent(id) => h.u64(1).u64(id.get()),
        _ => h.u64(u64::MAX),
    };
    h.u64(u64::try_from(decision.scope.path_prefixes.len()).unwrap_or(u64::MAX));
    for prefix in &decision.scope.path_prefixes {
        h.field(prefix.as_str().as_bytes());
    }
    h.i64(decision.at.as_millis());
    DecisionId::from_digest(h.finish())
}

/// Failures in the decision lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum DecisionError {
    /// An attestation named a different decision than the one it was applied
    /// to.
    #[error("attestation names a different decision")]
    WrongDecision,
    /// A statement was empty after trimming, or overlong.
    #[error("statement must be 1..=2000 characters of non-whitespace text")]
    InvalidStatement,
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "test assertions read better with unwrap"
)]
mod tests {
    use jac_core::AgentId;

    use super::*;

    fn base() -> Decision {
        Decision {
            title: "auth retry fails closed".to_owned(),
            rationale: "on 401/403 never retry with cached credentials".to_owned(),
            families: vec![RationaleFamily::Constraints],
            proposed_by: Author::Agent(AgentId::new(9).unwrap()),
            scope: DecisionScope {
                path_prefixes: vec![RepoPath::parse("src/auth").unwrap()],
            },
            at: Timestamp::from_millis(500),
        }
    }

    #[test]
    fn scope_change_changes_identity() {
        let narrow = base();
        let mut broad = base();
        broad
            .scope
            .path_prefixes
            .push(RepoPath::parse("src/net").unwrap());
        assert_ne!(decision_id(&narrow), decision_id(&broad));
    }

    #[test]
    fn scope_governs_segment_wise() {
        let d = base();
        assert!(
            d.scope
                .governs(&RepoPath::parse("src/auth/backoff.rs").unwrap())
        );
        assert!(
            !d.scope
                .governs(&RepoPath::parse("src/auth-x/lib.rs").unwrap())
        );
        assert!(!d.scope.governs(&RepoPath::parse("docs/readme.md").unwrap()));
    }

    #[test]
    fn empty_scope_governs_nothing() {
        let mut d = base();
        d.scope = DecisionScope::default();
        assert!(
            !d.scope
                .governs(&RepoPath::parse("src/auth/backoff.rs").unwrap())
        );
    }
}