//! The engine: where the ports meet.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use jac_core::{
Author, Clock, DecisionId, HumanIdentity, Provenance, RepoPath, SnapshotId, Timestamp,
};
use jac_decision::{
Attestation, Decision, DecisionError, DecisionLedger, DecisionRecord, Settled, Statement,
Unsettled,
};
use jac_gate::Verdict;
use jac_object::diff::DiffError;
use jac_object::{
Blob, ObjectError, ObjectStore, Segment, Snapshot, Tree, TreeEntry, TreeError, TreeNode,
diff_trees,
};
use jac_sketch::{ContentSketch, DecisionSketch, SketchBuilder};
use smallvec::SmallVec;
use thiserror::Error;
use crate::refs::{RefName, RefStore};
/// Failures in repository operations.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum RepoError {
/// A referenced object could not be read.
#[error("object store error: {0}")]
Store(#[from] ObjectError),
/// A tree could not be constructed from the given files.
#[error("tree construction failed: {0}")]
Tree(#[from] TreeError),
/// Diffing failed.
#[error("diff failed: {0}")]
Diff(#[from] DiffError),
/// A path in the file list was invalid.
#[error("invalid path: {0}")]
Path(jac_core::CoreError),
/// The named ref does not exist.
#[error("ref not found: {0}")]
RefNotFound(String),
/// The decision is not pending (unknown, or already settled).
#[error("no pending decision with that id")]
NoSuchPendingDecision,
/// The attestation did not settle the decision.
#[error("decision error: {0}")]
Decision(#[from] DecisionError),
/// Promotion is fast-forward-only in this milestone; the source branch
/// does not descend from the target.
#[error("promotion is fast-forward-only and `{from}` does not descend from `{into}`")]
NonFastForward {
/// Source ref.
from: String,
/// Target ref.
into: String,
},
}
/// The repository engine, generic over its ports.
#[derive(Debug)]
pub struct Repo<S, R, L, C> {
store: S,
refs: R,
ledger: L,
clock: C,
pending: BTreeMap<DecisionId, DecisionRecord<Unsettled>>,
settled: BTreeMap<DecisionId, DecisionRecord<Settled>>,
}
impl<S, R, L, C> Repo<S, R, L, C>
where
S: ObjectStore,
R: RefStore,
L: DecisionLedger,
C: Clock,
{
/// Assembles an engine from its ports.
#[must_use]
pub const fn new(store: S, refs: R, ledger: L, clock: C) -> Self {
Self {
store,
refs,
ledger,
clock,
pending: BTreeMap::new(),
settled: BTreeMap::new(),
}
}
/// Read access to the object store, for narration and bridging.
#[must_use]
pub const fn store(&self) -> &S {
&self.store
}
/// Mutable access to the object store, for the git bridge's import path.
pub const fn store_mut(&mut self) -> &mut S {
&mut self.store
}
/// Read access to the ledger, for the gate and narration.
#[must_use]
pub const fn ledger(&self) -> &L {
&self.ledger
}
/// Where a ref points, if anywhere.
#[must_use]
pub fn head(&self, name: &RefName) -> Option<SnapshotId> {
self.refs.get(name)
}
/// Points a ref at a snapshot directly — creating a branch.
///
/// Deliberately not gated: branching is how work *starts*; promotion is
/// where the gate stands.
pub fn branch(&mut self, name: &RefName, at: SnapshotId) {
self.refs.set(name, at);
}
/// The current time, from the injected clock.
#[must_use]
pub fn now(&self) -> Timestamp {
self.clock.now()
}
/// Mutable access to the clock, so a stepping clock can be advanced
/// between scenes in tests and the demo.
pub const fn clock_mut(&mut self) -> &mut C {
&mut self.clock
}
/// Commits a full file listing as a new snapshot on `branch`.
///
/// Milestone-1 honesty: the caller supplies the *entire* tree, not a
/// delta. The parent is the branch's current head, if any.
///
/// # Errors
///
/// Returns [`RepoError::Path`] or [`RepoError::Tree`] if the listing is
/// malformed.
pub fn commit(
&mut self,
branch: &RefName,
files: &[(&str, &[u8])],
author: Author,
provenance: Provenance,
message: &str,
) -> Result<SnapshotId, RepoError> {
let mut manifest = Vec::with_capacity(files.len());
for (path, content) in files {
let path = RepoPath::parse(path).map_err(RepoError::Path)?;
let blob = self.store.put_blob(Blob::new(content.to_vec()));
manifest.push((path, blob));
}
manifest.sort();
let tree = plant(&mut self.store, &manifest)?;
let parents = match self.refs.get(branch) {
Some(head) => SmallVec::from_slice(&[head]),
None => SmallVec::new(),
};
let snapshot = Snapshot {
tree,
parents,
author,
provenance,
message: message.to_owned(),
at: self.clock.now(),
};
let id = self.store.put_snapshot(snapshot);
self.refs.set(branch, id);
Ok(id)
}
/// Proposes a decision. Anyone may call this — including on behalf of an
/// agent; the decision's `proposed_by` says so.
pub fn propose_decision(&mut self, decision: Decision) -> DecisionId {
let record = DecisionRecord::propose(decision);
let id = record.id();
self.ledger.record_proposed(&record);
self.pending.insert(id, record);
id
}
/// A human attests a pending decision, settling it.
///
/// The typestate transition happens here and nowhere else in the
/// workspace: the pending `DecisionRecord<Unsettled>` is consumed and
/// only its `Settled` form is stored back.
///
/// # Errors
///
/// Returns [`RepoError::NoSuchPendingDecision`] if `id` is unknown or
/// already settled, or [`RepoError::Decision`] if the attestation fails.
pub fn attest(
&mut self,
id: DecisionId,
by: &HumanIdentity,
statement: Statement,
) -> Result<(), RepoError> {
let record = self
.pending
.remove(&id)
.ok_or(RepoError::NoSuchPendingDecision)?;
let attestation = Attestation::new(by, id, statement, self.clock.now());
let settled = record.attest(attestation)?;
self.ledger.record_settled(&settled);
self.settled.insert(id, settled);
Ok(())
}
/// The decision behind an id, pending or settled, for narration and
/// sketching.
#[must_use]
pub fn decision(&self, id: DecisionId) -> Option<&Decision> {
self.pending
.get(&id)
.map(DecisionRecord::decision)
.or_else(|| self.settled.get(&id).map(DecisionRecord::decision))
}
/// Paths that differ between two snapshots' trees.
///
/// # Errors
///
/// Returns [`RepoError::Store`] or [`RepoError::Diff`] on read failures.
pub fn changed_between(
&self,
a: SnapshotId,
b: SnapshotId,
) -> Result<BTreeSet<RepoPath>, RepoError> {
let tree_a = self.store.snapshot(a)?.tree;
let tree_b = self.store.snapshot(b)?.tree;
Ok(diff_trees(&self.store, tree_a, tree_b)?)
}
/// Promotes `from` into `into`, fast-forward-only, gated.
///
/// The blast radius is the tree diff between the two heads; the verdict
/// is [`jac_gate::evaluate`] over it. The ref moves only on a literal
/// [`Verdict::Admitted`] — the wildcard is the blocked path, so a future
/// verdict variant fails closed.
///
/// # Errors
///
/// Returns [`RepoError::RefNotFound`] if `from` is unbound, or
/// [`RepoError::NonFastForward`] if `into` exists and is not an ancestor
/// of `from`. A blocked verdict is a *result*, not an error: the caller
/// gets it back to route a human to the unsettled decisions.
pub fn promote(&mut self, from: &RefName, into: &RefName) -> Result<Verdict, RepoError> {
let from_head = self
.refs
.get(from)
.ok_or_else(|| RepoError::RefNotFound(from.as_str().to_owned()))?;
let changed = if let Some(into_head) = self.refs.get(into) {
if !self.descends_from(from_head, into_head)? {
return Err(RepoError::NonFastForward {
from: from.as_str().to_owned(),
into: into.as_str().to_owned(),
});
}
self.changed_between(into_head, from_head)?
} else {
// A new target ref: everything in `from` is the blast radius.
let empty = self.store.put_tree(Tree::empty());
let from_tree = self.store.snapshot(from_head)?.tree;
diff_trees(&self.store, empty, from_tree)?
};
let verdict = jac_gate::evaluate(&self.ledger, &changed);
// Only the literal Admitted variant moves the ref; anything else —
// present or future — moves nothing.
if let Verdict::Admitted { .. } = verdict {
self.refs.set(into, from_head);
}
Ok(verdict)
}
/// Whether `descendant` can reach `ancestor` through parent edges.
fn descends_from(
&self,
descendant: SnapshotId,
ancestor: SnapshotId,
) -> Result<bool, RepoError> {
if descendant == ancestor {
return Ok(true);
}
let mut seen = BTreeSet::new();
let mut queue = VecDeque::from([descendant]);
while let Some(id) = queue.pop_front() {
if id == ancestor {
return Ok(true);
}
if !seen.insert(id) {
continue;
}
for parent in &self.store.snapshot(id)?.parents {
queue.push_back(*parent);
}
}
Ok(false)
}
/// Sketches a snapshot's content for the registry.
///
/// The one sanctioned crossing: content in, [`ContentSketch`] out. Blob
/// bytes are read as lossy UTF-8 and fed through the shingler along with
/// their paths; what returns is 64 minima and nothing else.
///
/// # Errors
///
/// Returns [`RepoError::Store`] on read failures.
pub fn sketch_snapshot(&self, id: SnapshotId) -> Result<ContentSketch, RepoError> {
let tree = self.store.snapshot(id)?.tree;
let mut builder = SketchBuilder::new();
self.feed_tree(tree, &mut builder)?;
Ok(ContentSketch(builder.finish()))
}
fn feed_tree(
&self,
tree: jac_core::TreeId,
builder: &mut SketchBuilder,
) -> Result<(), RepoError> {
let tree = self.store.tree(tree)?;
for entry in tree.entries() {
builder.feed_text(entry.name.as_str());
match entry.node {
TreeNode::Blob(id) => {
let blob = self.store.blob(id)?;
builder.feed_text(&String::from_utf8_lossy(blob.as_bytes()));
}
TreeNode::Tree(id) => self.feed_tree(id, builder)?,
// An unknown node kind contributes nothing to the sketch
// rather than failing a publish.
_ => {}
}
}
Ok(())
}
/// Sketches a decision (title and rationale) for the registry.
///
/// # Errors
///
/// Returns [`RepoError::NoSuchPendingDecision`] if the id is unknown.
pub fn sketch_decision(&self, id: DecisionId) -> Result<DecisionSketch, RepoError> {
let decision = self.decision(id).ok_or(RepoError::NoSuchPendingDecision)?;
let mut builder = SketchBuilder::new();
builder.feed_text(&decision.title);
builder.feed_text(&decision.rationale);
Ok(DecisionSketch(builder.finish()))
}
}
/// Builds nested trees from a sorted `(path, blob)` manifest, returning the
/// root tree id.
fn plant<S: ObjectStore>(
store: &mut S,
manifest: &[(RepoPath, jac_core::BlobId)],
) -> Result<jac_core::TreeId, RepoError> {
fn build<S: ObjectStore>(
store: &mut S,
items: &[(String, jac_core::BlobId)],
) -> Result<jac_core::TreeId, RepoError> {
let mut blobs: Vec<(String, jac_core::BlobId)> = Vec::new();
let mut children: BTreeMap<String, Vec<(String, jac_core::BlobId)>> = BTreeMap::new();
for (path, blob) in items {
match path.split_once('/') {
None => blobs.push((path.clone(), *blob)),
Some((head, rest)) => children
.entry(head.to_owned())
.or_default()
.push((rest.to_owned(), *blob)),
}
}
let mut entries = Vec::new();
for (name, blob) in blobs {
entries.push(TreeEntry {
name: Segment::parse(&name)?,
node: TreeNode::Blob(blob),
});
}
for (name, inner) in children {
let sub = build(store, &inner)?;
entries.push(TreeEntry {
name: Segment::parse(&name)?,
node: TreeNode::Tree(sub),
});
}
Ok(store.put_tree(Tree::new(entries)?))
}
let items: Vec<(String, jac_core::BlobId)> = manifest
.iter()
.map(|(p, b)| (p.as_str().to_owned(), *b))
.collect();
build(store, &items)
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "test assertions read better with unwrap"
)]
mod tests {
use jac_core::{AgentId, FixedClock, HumanId};
use jac_decision::{DecisionScope, MemoryLedger, RationaleFamily};
use jac_object::MemoryObjectStore;
use super::*;
use crate::refs::MemoryRefStore;
type TestRepo = Repo<MemoryObjectStore, MemoryRefStore, MemoryLedger, FixedClock>;
fn repo() -> TestRepo {
Repo::new(
MemoryObjectStore::new(),
MemoryRefStore::new(),
MemoryLedger::new(),
FixedClock::new(Timestamp::from_millis(1_000)),
)
}
fn ada() -> HumanIdentity {
HumanIdentity {
id: HumanId::new(1).unwrap(),
display_name: "Ada".to_owned(),
}
}
fn loom_bot() -> Author {
Author::Agent(AgentId::new(2).unwrap())
}
#[test]
fn the_whole_gate_story() {
let mut repo = repo();
let main = RefName::parse("main").unwrap();
let feature = RefName::parse("feature/backoff").unwrap();
// Ada lays down main.
repo.commit(
&main,
&[("src/auth/login.rs", b"fn login() {}")],
Author::Human(HumanId::new(1).unwrap()),
Provenance::Human,
"initial auth",
)
.unwrap();
// The agent branches from main and adds a retry layer.
let main_head = repo.head(&main).unwrap();
repo.refs.set(&feature, main_head);
repo.commit(
&feature,
&[
("src/auth/login.rs", b"fn login() {}"),
("src/auth/backoff.rs", b"fn retry() {}"),
],
loom_bot(),
Provenance::Agent,
"add retry backoff",
)
.unwrap();
// The agent proposes the governing decision; unsettled.
let decision_id = repo.propose_decision(Decision {
title: "auth retry fails closed".to_owned(),
rationale: "drafted from the diff by the interviewer".to_owned(),
families: vec![RationaleFamily::Constraints],
proposed_by: loom_bot(),
scope: DecisionScope {
path_prefixes: vec![RepoPath::parse("src/auth").unwrap()],
},
at: Timestamp::from_millis(1_000),
});
// Blocked: the blast radius touches an unsettled decision.
let verdict = repo.promote(&feature, &main).unwrap();
assert!(!verdict.is_admitted());
assert_eq!(repo.head(&main), Some(main_head), "the ref did not move");
// Ada attests; the same promote admits.
repo.attest(
decision_id,
&ada(),
Statement::new("Walked the 401 path; the retry gives up rather than replaying.")
.unwrap(),
)
.unwrap();
let verdict = repo.promote(&feature, &main).unwrap();
assert_eq!(
verdict,
Verdict::Admitted {
decisions_checked: 1
}
);
assert_eq!(repo.head(&main), repo.head(&feature));
}
#[test]
fn non_fast_forward_is_refused() {
let mut repo = repo();
let main = RefName::parse("main").unwrap();
let stray = RefName::parse("stray").unwrap();
repo.commit(
&main,
&[("a.rs", b"1")],
loom_bot(),
Provenance::Agent,
"on main",
)
.unwrap();
// `stray` has no shared history with main.
repo.commit(
&stray,
&[("b.rs", b"2")],
loom_bot(),
Provenance::Agent,
"unrelated",
)
.unwrap();
assert!(matches!(
repo.promote(&stray, &main),
Err(RepoError::NonFastForward { .. })
));
}
#[test]
fn attesting_twice_is_refused() {
let mut repo = repo();
let id = repo.propose_decision(Decision {
title: "t".to_owned(),
rationale: "r".to_owned(),
families: vec![],
proposed_by: loom_bot(),
scope: DecisionScope::default(),
at: Timestamp::from_millis(1),
});
repo.attest(id, &ada(), Statement::new("read it").unwrap())
.unwrap();
assert!(matches!(
repo.attest(id, &ada(), Statement::new("again").unwrap()),
Err(RepoError::NoSuchPendingDecision)
));
}
#[test]
fn sketches_come_out_digest_shaped() {
let mut repo = repo();
let main = RefName::parse("main").unwrap();
repo.commit(
&main,
&[(
"src/auth/backoff.rs",
b"retry with exponential backoff fails closed",
)],
loom_bot(),
Provenance::Agent,
"retry",
)
.unwrap();
let head = repo.head(&main).unwrap();
let sketch = repo.sketch_snapshot(head).unwrap();
// The only thing a holder can do with it: compare.
assert_eq!(sketch.similarity(&sketch).permille(), 1000);
}
}