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