use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum LedgerStatus {
Pending,
Verified,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Journal {
#[serde(default, rename = "row")]
pub rows: Vec<JournalRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct JournalRow {
pub source_oid: String,
pub target_ref: String,
pub expected_old_oid: String,
pub planned_new_oid: String,
#[serde(default)]
pub applied_new_oid: String,
pub status: LedgerStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Boundaries {
#[serde(default, rename = "boundary")]
pub rows: Vec<BoundaryRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct BoundaryRow {
pub phase: String,
pub code_start_oid: String,
pub code_end_oid: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Orthogonal {
#[serde(default, rename = "mark")]
pub rows: Vec<OrthogonalMark>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct OrthogonalMark {
pub entity: String,
pub path: String,
pub status: LedgerStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidateKind {
Audit,
Experiment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidateRole {
ReviewSurface,
CloseTarget,
Scratch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidatePayload {
ImplBundle,
Code,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CandidateStatus {
Created,
Conflicted,
Abandoned,
Superseded,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct Candidates {
#[serde(default, rename = "candidate")]
pub rows: Vec<CandidateRow>,
#[serde(default)]
pub current_admission: CurrentAdmission,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct CandidateRow {
pub id: String,
pub label: String,
pub kind: CandidateKind,
pub role: CandidateRole,
pub payload: CandidatePayload,
pub target_ref: String,
pub source_ref: String,
pub source_oid: String,
pub base_ref: String,
pub base_oid: String,
pub merge_oid: String,
pub status: CandidateStatus,
#[serde(default)]
pub supersedes: String,
#[serde(default)]
pub reason: String,
pub created_by: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct CurrentAdmission {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub close_target: Option<Admission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_surface: Option<Admission>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Admission {
pub candidate_id: String,
pub candidate_ref: String,
pub expected_ref_oid: String,
pub admitted_oid: String,
pub review: String,
#[serde(default)]
pub supersedes: String,
pub admitted_at: String,
}
impl Candidates {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "candidate create/status/admit are the first non-test callers (PHASE-02+)"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Candidates> {
Ok(toml::from_str(text)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "candidate create/status/admit are the first non-test callers (PHASE-02+)"
)
)]
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "candidate create/status/admit are the first non-test callers (PHASE-02+)"
)
)]
pub(crate) fn set_candidate_status(&mut self, id: &str, status: CandidateStatus) -> bool {
match self.rows.iter_mut().find(|r| r.id == id) {
Some(row) => {
row.status = status;
true
}
None => false,
}
}
}
impl Journal {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate is the first non-test reader (PHASE-05)"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Journal> {
Ok(toml::from_str(text)?)
}
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
}
impl Boundaries {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate / funnel are the first non-test callers"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Boundaries> {
Ok(toml::from_str(text)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time recording is the first non-test writer (PHASE-06)"
)
)]
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
}
impl Orthogonal {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate / funnel are the first non-test callers"
)
)]
pub(crate) fn parse(text: &str) -> anyhow::Result<Orthogonal> {
Ok(toml::from_str(text)?)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time recording is the first non-test writer (PHASE-06)"
)
)]
pub(crate) fn to_toml(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
}
fn dispatch_dir(root: &Path, slice: u32) -> PathBuf {
root.join(".doctrine")
.join("dispatch")
.join(format!("{slice:03}"))
}
fn load<T: DeserializeOwned + Default>(path: &Path) -> anyhow::Result<T> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(toml::from_str(&text)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(e.into()),
}
}
fn store<T: Serialize>(path: &Path, manifest: &T) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, toml::to_string(manifest)?)?;
Ok(())
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stage-2 integrate is the first non-test reader (PHASE-05)"
)
)]
pub(crate) fn read_journal(root: &Path, slice: u32) -> anyhow::Result<Journal> {
load(&dispatch_dir(root, slice).join("journal.toml"))
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time read-modify-write side; the sync verb tree-reads instead (read_path_at)"
)
)]
pub(crate) fn read_boundaries(root: &Path, slice: u32) -> anyhow::Result<Boundaries> {
load(&dispatch_dir(root, slice).join("boundaries.toml"))
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "funnel-time read-modify-write side; the sync verb tree-reads instead (read_path_at)"
)
)]
pub(crate) fn read_orthogonal(root: &Path, slice: u32) -> anyhow::Result<Orthogonal> {
load(&dispatch_dir(root, slice).join("orthogonal.toml"))
}
pub(crate) fn record_boundary(root: &Path, slice: u32, row: BoundaryRow) -> anyhow::Result<()> {
let path = dispatch_dir(root, slice).join("boundaries.toml");
let mut manifest: Boundaries = load(&path)?;
manifest.rows.push(row);
store(&path, &manifest)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "no funnel verb yet — its driver is the OQ-B orthogonal classifier (deferred plan-gate); empty orthogonal.toml is the conservative EXCLUDE fallback (IMP backlog)"
)
)]
pub(crate) fn record_orthogonal(
root: &Path,
slice: u32,
mark: OrthogonalMark,
) -> anyhow::Result<()> {
let path = dispatch_dir(root, slice).join("orthogonal.toml");
let mut manifest: Orthogonal = load(&path)?;
manifest.rows.push(mark);
store(&path, &manifest)
}
pub(crate) fn read_candidates(root: &Path, slice: u32) -> anyhow::Result<Candidates> {
load(&dispatch_dir(root, slice).join("candidates.toml"))
}
pub(crate) fn write_candidates(
root: &Path,
slice: u32,
candidates: &Candidates,
) -> anyhow::Result<()> {
store(
&dispatch_dir(root, slice).join("candidates.toml"),
candidates,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn journal_round_trips_and_pins_field_names() {
let journal = Journal {
rows: vec![JournalRow {
source_oid: "aaa".into(),
target_ref: "refs/review/64".into(),
expected_old_oid: "0".repeat(40),
planned_new_oid: "bbb".into(),
applied_new_oid: String::new(),
status: LedgerStatus::Pending,
}],
};
let text = journal.to_toml().expect("serialize");
assert!(text.contains("[[row]]"), "table header: {text}");
assert!(text.contains("source_oid ="), "{text}");
assert!(text.contains("target_ref ="), "{text}");
assert!(text.contains("expected_old_oid ="), "{text}");
assert!(text.contains("planned_new_oid ="), "{text}");
assert!(text.contains("applied_new_oid ="), "{text}");
assert!(
text.contains("status = \"pending\""),
"lowercase token: {text}"
);
assert_eq!(Journal::parse(&text).expect("parse"), journal);
}
#[test]
fn boundaries_round_trip_and_orthogonal_round_trip() {
let boundaries = Boundaries {
rows: vec![BoundaryRow {
phase: "PHASE-03".into(),
code_start_oid: "s".into(),
code_end_oid: "e".into(),
}],
};
let text = boundaries.to_toml().expect("ser");
assert!(text.contains("[[boundary]]"), "{text}");
assert!(text.contains("phase = \"PHASE-03\""), "{text}");
assert_eq!(Boundaries::parse(&text).expect("parse"), boundaries);
let orthogonal = Orthogonal {
rows: vec![OrthogonalMark {
entity: "ADR-012".into(),
path: ".doctrine/adr/012".into(),
status: LedgerStatus::Verified,
}],
};
let text = orthogonal.to_toml().expect("ser");
assert!(text.contains("[[mark]]"), "{text}");
assert!(text.contains("status = \"verified\""), "{text}");
assert_eq!(Orthogonal::parse(&text).expect("parse"), orthogonal);
}
#[test]
fn empty_manifests_round_trip() {
for text in [
Journal::default().to_toml().unwrap(),
Boundaries::default().to_toml().unwrap(),
Orthogonal::default().to_toml().unwrap(),
] {
assert!(Journal::parse(&text).is_ok());
}
assert_eq!(Journal::parse("").unwrap(), Journal::default());
assert_eq!(Boundaries::parse("").unwrap(), Boundaries::default());
assert_eq!(Orthogonal::parse("").unwrap(), Orthogonal::default());
}
#[test]
fn record_then_read_round_trips_through_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let slice = 64;
assert_eq!(read_boundaries(root, slice).unwrap(), Boundaries::default());
assert_eq!(read_orthogonal(root, slice).unwrap(), Orthogonal::default());
record_boundary(
root,
slice,
BoundaryRow {
phase: "PHASE-01".into(),
code_start_oid: "s1".into(),
code_end_oid: "e1".into(),
},
)
.expect("record boundary 1");
record_boundary(
root,
slice,
BoundaryRow {
phase: "PHASE-02".into(),
code_start_oid: "s2".into(),
code_end_oid: "e2".into(),
},
)
.expect("record boundary 2");
record_orthogonal(
root,
slice,
OrthogonalMark {
entity: "ADR-012".into(),
path: ".doctrine/adr/012".into(),
status: LedgerStatus::Verified,
},
)
.expect("record mark");
assert!(root.join(".doctrine/dispatch/064/boundaries.toml").exists());
let boundaries = read_boundaries(root, slice).unwrap();
let phases: Vec<&str> = boundaries.rows.iter().map(|r| r.phase.as_str()).collect();
assert_eq!(phases, vec!["PHASE-01", "PHASE-02"]);
let orthogonal = read_orthogonal(root, slice).unwrap();
assert_eq!(orthogonal.rows.len(), 1);
assert_eq!(orthogonal.rows[0].status, LedgerStatus::Verified);
assert_eq!(read_journal(root, slice).unwrap(), Journal::default());
}
fn sample_candidate(id: &str, label: &str, status: CandidateStatus) -> CandidateRow {
CandidateRow {
id: id.into(),
label: label.into(),
kind: CandidateKind::Audit,
role: CandidateRole::ReviewSurface,
payload: CandidatePayload::ImplBundle,
target_ref: format!("refs/heads/candidate/068/{label}"),
source_ref: "refs/heads/review/068".into(),
source_oid: "src-oid".into(),
base_ref: "refs/heads/main".into(),
base_oid: "base-oid".into(),
merge_oid: "merge-oid".into(),
status,
supersedes: String::new(),
reason: String::new(),
created_by: "dispatch candidate create".into(),
created_at: "2026-06-15".into(),
}
}
#[test]
fn candidates_round_trip_and_pin_field_names() {
let manifest = Candidates {
rows: vec![
sample_candidate(
"cand-068-review-001",
"review-001",
CandidateStatus::Created,
),
sample_candidate(
"cand-068-review-002",
"review-002",
CandidateStatus::Conflicted,
),
],
current_admission: CurrentAdmission {
close_target: Some(Admission {
candidate_id: "cand-068-close-001".into(),
candidate_ref: "refs/heads/candidate/068/close-001".into(),
expected_ref_oid: "ref-oid".into(),
admitted_oid: "admitted-oid".into(),
review: "RV-007".into(),
supersedes: String::new(),
admitted_at: "2026-06-15".into(),
}),
review_surface: None,
},
};
let text = manifest.to_toml().expect("serialize");
assert!(text.contains("[[candidate]]"), "table header: {text}");
assert!(text.contains("id ="), "{text}");
assert!(text.contains("label ="), "{text}");
assert!(text.contains("target_ref ="), "{text}");
assert!(text.contains("source_oid ="), "{text}");
assert!(text.contains("base_oid ="), "{text}");
assert!(text.contains("merge_oid ="), "{text}");
assert!(text.contains("created_by ="), "{text}");
assert!(text.contains("created_at ="), "{text}");
assert!(text.contains("status = \"created\""), "{text}");
assert!(text.contains("role = \"review_surface\""), "{text}");
assert!(text.contains("kind = \"audit\""), "{text}");
assert!(text.contains("payload = \"impl_bundle\""), "{text}");
assert!(
text.contains("[current_admission.close_target]"),
"admission table: {text}"
);
assert_eq!(Candidates::parse(&text).expect("parse"), manifest);
}
#[test]
fn candidates_empty_and_absent_default() {
assert_eq!(Candidates::parse("").unwrap(), Candidates::default());
let text = Candidates::default().to_toml().unwrap();
assert_eq!(Candidates::parse(&text).unwrap(), Candidates::default());
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
read_candidates(dir.path(), 68).unwrap(),
Candidates::default()
);
}
#[test]
fn candidates_reject_unknown_tokens() {
let bad_role = r#"
[[candidate]]
id = "x"
label = "x"
kind = "audit"
role = "bogus"
payload = "impl_bundle"
target_ref = "r"
source_ref = "r"
source_oid = "o"
base_ref = "r"
base_oid = "o"
merge_oid = "o"
status = "created"
created_by = "v"
created_at = "d"
"#;
assert!(Candidates::parse(bad_role).is_err(), "bogus role must fail");
let bad_kind = bad_role
.replace("role = \"bogus\"", "role = \"review_surface\"")
.replace("kind = \"audit\"", "kind = \"bogus\"");
assert!(
Candidates::parse(&bad_kind).is_err(),
"bogus kind must fail"
);
let bad_payload = bad_role
.replace("role = \"bogus\"", "role = \"review_surface\"")
.replace("payload = \"impl_bundle\"", "payload = \"bogus\"");
assert!(
Candidates::parse(&bad_payload).is_err(),
"bogus payload must fail"
);
}
#[test]
fn set_candidate_status_mutates_only_status() {
let mut manifest = Candidates {
rows: vec![sample_candidate(
"cand-068-review-001",
"review-001",
CandidateStatus::Created,
)],
current_admission: CurrentAdmission::default(),
};
let before = manifest.rows[0].clone();
assert!(manifest.set_candidate_status("cand-068-review-001", CandidateStatus::Abandoned));
assert!(!manifest.set_candidate_status("nope", CandidateStatus::Abandoned));
let after = &manifest.rows[0];
assert_eq!(after.status, CandidateStatus::Abandoned);
assert_eq!(after.id, before.id);
assert_eq!(after.label, before.label);
assert_eq!(after.kind, before.kind);
assert_eq!(after.role, before.role);
assert_eq!(after.payload, before.payload);
assert_eq!(after.target_ref, before.target_ref);
assert_eq!(after.source_ref, before.source_ref);
assert_eq!(after.source_oid, before.source_oid);
assert_eq!(after.base_ref, before.base_ref);
assert_eq!(after.base_oid, before.base_oid);
assert_eq!(after.merge_oid, before.merge_oid);
assert_eq!(after.created_by, before.created_by);
assert_eq!(after.created_at, before.created_at);
}
}