jacquardSnapshot

← snapshot

8528 bytes
//! The attunement gate: pure, sans-IO, fail-closed.
//!
//! One function — [`evaluate`] — answers one question: given the paths a
//! change touched, do any decisions governing those paths remain unsettled?
//! Every failure mode lands on [`Verdict::Blocked`]: a decision the ledger
//! names but cannot report a status for, an unrecognised status variant from
//! a future ledger, a verdict nobody computed. Unproven is not passed.
//!
//! Honest scope, stated once and repeated in the demo: admitting a change
//! with *no* governing decisions means the gate binds **declared** scopes
//! only. It proves "every settled decision in the blast radius was attested
//! by a human"; it does not prove the change is good, and it does not invent
//! governance where none was declared.
//!
//! Nobody is punished by a block. The verdict names the unsettled decisions
//! so the surface can route a human to them; the work parks, the decision
//! surfaces, and attestation — recognised, never policed — reopens the way.

use std::collections::BTreeSet;

use jac_core::{DecisionId, RepoPath};
use jac_decision::{DecisionLedger, DecisionStatus};

/// Why a change is blocked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BlockReason {
    /// No evaluation has happened. The default state of the world.
    NotEvaluated,
    /// Governing decisions exist that no human has attested.
    UnsettledDecisions,
    /// The ledger named a decision it could not report a status for.
    LedgerInconsistent,
}

/// The gate's answer.
///
/// The `Default` is the restrictive variant. A `GateReport` nobody filled in
/// blocks; a match arm nobody wrote blocks (consumers match
/// `Admitted { .. }` and treat everything else as blocked).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Verdict {
    /// The change may not promote.
    Blocked {
        /// The unsettled decisions in the blast radius, for routing a human
        /// to them. Empty when the block is structural (not evaluated,
        /// ledger inconsistency).
        unsettled: Vec<DecisionId>,
        /// Why.
        reason: BlockReason,
    },
    /// Every governing decision in the blast radius is settled.
    Admitted {
        /// How many settled decisions were checked. Zero is reported
        /// honestly: it means the blast radius was ungoverned, not that
        /// anything was verified.
        decisions_checked: usize,
    },
}

impl Default for Verdict {
    /// Unproven is not passed.
    fn default() -> Self {
        Self::Blocked {
            unsettled: Vec::new(),
            reason: BlockReason::NotEvaluated,
        }
    }
}

impl Verdict {
    /// Whether this verdict admits the change.
    ///
    /// Matches the `Admitted` variant literally; any other variant — present
    /// or future — is not an admission.
    #[must_use]
    pub const fn is_admitted(&self) -> bool {
        matches!(self, Self::Admitted { .. })
    }
}

/// Evaluates a change's blast radius against the decision ledger.
///
/// Pure: paths in, verdict out. The ledger is read, never written.
#[must_use]
pub fn evaluate(ledger: &dyn DecisionLedger, changed: &BTreeSet<RepoPath>) -> Verdict {
    let mut unsettled = BTreeSet::new();
    let mut settled = BTreeSet::new();

    for path in changed {
        for (id, status) in ledger.governing(path) {
            match status {
                DecisionStatus::Settled => {
                    settled.insert(id);
                }
                DecisionStatus::Unsettled => {
                    unsettled.insert(id);
                }
                // A status this gate does not recognise is not a settlement.
                _ => {
                    return Verdict::Blocked {
                        unsettled: vec![id],
                        reason: BlockReason::LedgerInconsistent,
                    };
                }
            }
        }
    }

    // Cross-check: every decision the ledger claimed must still answer for
    // itself. A ledger that names a decision and then cannot report it is
    // not a ledger the gate can trust.
    for id in unsettled.iter().chain(settled.iter()) {
        if ledger.status(*id).is_none() {
            return Verdict::Blocked {
                unsettled: vec![*id],
                reason: BlockReason::LedgerInconsistent,
            };
        }
    }

    if unsettled.is_empty() {
        Verdict::Admitted {
            decisions_checked: settled.len(),
        }
    } else {
        Verdict::Blocked {
            unsettled: unsettled.into_iter().collect(),
            reason: BlockReason::UnsettledDecisions,
        }
    }
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "test assertions read better with unwrap"
)]
mod tests {
    use jac_core::{AgentId, Author, HumanId, HumanIdentity, Timestamp};
    use jac_decision::{
        Attestation, Decision, DecisionRecord, DecisionScope, MemoryLedger, RationaleFamily,
        Statement,
    };

    use super::*;

    fn paths(list: &[&str]) -> BTreeSet<RepoPath> {
        list.iter().map(|p| RepoPath::parse(p).unwrap()).collect()
    }

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

    #[test]
    fn default_verdict_is_blocked() {
        assert!(!Verdict::default().is_admitted());
        assert_eq!(
            Verdict::default(),
            Verdict::Blocked {
                unsettled: Vec::new(),
                reason: BlockReason::NotEvaluated
            }
        );
    }

    #[test]
    fn unsettled_decision_in_scope_blocks() {
        let mut ledger = MemoryLedger::new();
        let record = auth_decision();
        let id = record.id();
        ledger.record_proposed(&record);

        let verdict = evaluate(&ledger, &paths(&["src/auth/backoff.rs"]));
        assert_eq!(
            verdict,
            Verdict::Blocked {
                unsettled: vec![id],
                reason: BlockReason::UnsettledDecisions
            }
        );
    }

    #[test]
    fn settled_decision_admits_and_is_counted() {
        let mut ledger = MemoryLedger::new();
        let record = auth_decision();
        let id = record.id();
        let ada = HumanIdentity {
            id: HumanId::new(1).unwrap(),
            display_name: "Ada".to_owned(),
        };
        let settled = record
            .attest(Attestation::new(
                &ada,
                id,
                Statement::new("walked the 401 path, fails closed").unwrap(),
                Timestamp::from_millis(2),
            ))
            .unwrap();
        ledger.record_settled(&settled);

        let verdict = evaluate(&ledger, &paths(&["src/auth/backoff.rs"]));
        assert_eq!(
            verdict,
            Verdict::Admitted {
                decisions_checked: 1
            }
        );
    }

    #[test]
    fn ungoverned_paths_admit_with_zero_checked() {
        let mut ledger = MemoryLedger::new();
        ledger.record_proposed(&auth_decision());

        let verdict = evaluate(&ledger, &paths(&["docs/readme.md"]));
        assert_eq!(
            verdict,
            Verdict::Admitted {
                decisions_checked: 0
            },
            "the gate binds declared scopes only, and says so via the count"
        );
    }

    #[test]
    fn out_of_scope_unsettled_decisions_do_not_block() {
        let mut ledger = MemoryLedger::new();
        ledger.record_proposed(&auth_decision());

        // `src/auth-x` is a string-prefix of nothing under `src/auth`
        // segment-wise; the classic prefix bug must not create governance.
        let verdict = evaluate(&ledger, &paths(&["src/auth-x/lib.rs"]));
        assert!(verdict.is_admitted());
    }

    #[test]
    fn empty_change_admits_trivially() {
        let ledger = MemoryLedger::new();
        let verdict = evaluate(&ledger, &BTreeSet::new());
        assert_eq!(
            verdict,
            Verdict::Admitted {
                decisions_checked: 0
            }
        );
    }
}