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