jacquardSnapshot

← snapshot

4898 bytes
//! The ledger port: what the gate reads.

use std::collections::BTreeMap;

use jac_core::{DecisionId, RepoPath};

use crate::decision::DecisionScope;
use crate::settle::{DecisionRecord, Settled, Unsettled};

/// A decision's settlement status, as the ledger reports it.
///
/// A plain enum rather than the typestate: the gate needs to ask about
/// decisions it does not hold, and an answer is data. The typestate governs
/// *transitions*; the ledger reports *observations*.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DecisionStatus {
    /// Proposed, awaiting a human.
    Unsettled,
    /// Attested.
    Settled,
}

/// Where decision statuses are recorded and queried.
///
/// Sans-IO port; the in-memory fake is [`MemoryLedger`]. The gate consumes
/// this trait and nothing else, so anything that can answer "which decisions
/// govern this path, and are they settled?" can back the gate.
pub trait DecisionLedger {
    /// Records a freshly proposed decision.
    fn record_proposed(&mut self, record: &DecisionRecord<Unsettled>);

    /// Records that a decision has been settled.
    fn record_settled(&mut self, record: &DecisionRecord<Settled>);

    /// The status of one decision, if known.
    fn status(&self, id: DecisionId) -> Option<DecisionStatus>;

    /// Every decision whose scope governs `path`, with its status.
    fn governing(&self, path: &RepoPath) -> Vec<(DecisionId, DecisionStatus)>;
}

/// In-memory ledger for tests and the narrated demo.
#[derive(Debug, Default)]
pub struct MemoryLedger {
    entries: BTreeMap<DecisionId, (DecisionScope, DecisionStatus)>,
}

impl MemoryLedger {
    /// An empty ledger.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Number of recorded decisions, for narration.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the ledger is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

impl DecisionLedger for MemoryLedger {
    fn record_proposed(&mut self, record: &DecisionRecord<Unsettled>) {
        self.entries.insert(
            record.id(),
            (record.decision().scope.clone(), DecisionStatus::Unsettled),
        );
    }

    fn record_settled(&mut self, record: &DecisionRecord<Settled>) {
        self.entries.insert(
            record.id(),
            (record.decision().scope.clone(), DecisionStatus::Settled),
        );
    }

    fn status(&self, id: DecisionId) -> Option<DecisionStatus> {
        self.entries.get(&id).map(|(_, status)| *status)
    }

    fn governing(&self, path: &RepoPath) -> Vec<(DecisionId, DecisionStatus)> {
        self.entries
            .iter()
            .filter(|(_, (scope, _))| scope.governs(path))
            .map(|(id, (_, status))| (*id, *status))
            .collect()
    }
}

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

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

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

    #[test]
    fn ledger_tracks_the_lifecycle() {
        let mut ledger = MemoryLedger::new();
        let record = proposal();
        let id = record.id();

        ledger.record_proposed(&record);
        assert_eq!(ledger.status(id), Some(DecisionStatus::Unsettled));

        let ada = HumanIdentity {
            id: HumanId::new(1).unwrap(),
            display_name: "Ada".to_owned(),
        };
        let settled = record
            .attest(Attestation::new(
                &ada,
                id,
                Statement::new("read it, holds").unwrap(),
                Timestamp::from_millis(2),
            ))
            .unwrap();
        ledger.record_settled(&settled);
        assert_eq!(ledger.status(id), Some(DecisionStatus::Settled));
    }

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

        let inside = RepoPath::parse("src/auth/backoff.rs").unwrap();
        let outside = RepoPath::parse("src/net/pool.rs").unwrap();
        assert_eq!(ledger.governing(&inside).len(), 1);
        assert!(ledger.governing(&outside).is_empty());
    }
}