use std::collections::BTreeSet;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, bail};
use clap::Subcommand;
use crate::slice::parse_cli_id;
use crate::worktree::JailPolicy;
use crate::boundary::{BoundaryRow, Provenance as BoundaryProvenance};
use crate::corpus_guard;
use crate::funnel_machine::Position;
use crate::git::{self, MergeTree, RefCas, ZERO_OID};
use crate::kinds::{
CANDIDATE_REF_PREFIX, DISPATCH_REF_PREFIX, PHASE_REF_PREFIX, REVIEW_REF_PREFIX,
};
use crate::ledger::{
Admission, Boundaries, CandidateKind, CandidatePayload, CandidateRole, CandidateRow,
CandidateStatus, Candidates, Journal, JournalRow, LedgerStatus, Orthogonal, read_candidates,
};
use crate::listing::render_table;
use crate::root;
use crate::worktree::run_provision;
#[derive(Subcommand)]
pub(crate) enum DispatchCommand {
Sync {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long, group = "stage", required = true)]
prepare_review: bool,
#[arg(long, group = "stage", required = true)]
integrate: bool,
#[arg(long, group = "stage", required = true)]
show_journal_trunk_oid: bool,
#[arg(long, group = "stage", required = true)]
record_integration: bool,
#[arg(
long,
conflicts_with = "prepare_review",
required_if_eq("record_integration", "true")
)]
trunk: Option<String>,
#[arg(long, requires = "integrate")]
edge: Option<String>,
#[arg(long = "allow-corpus-clobber", requires = "integrate")]
allow_corpus_clobber: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
RecordBoundary {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long)]
phase: String,
#[arg(long)]
code_start: String,
#[arg(long)]
code_end: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
RefreshBase {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Setup {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long)]
dir: PathBuf,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Candidate {
#[command(subcommand)]
command: CandidateCommand,
},
PlanNext {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
DeliverTo {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
ArmSpawn {
#[arg(long)]
base: Option<String>,
#[arg(long, value_parser = parse_cli_id)]
slice: Option<u32>,
#[arg(long)]
phase: Option<String>,
#[arg(long = "extra-rw")]
extra_rw: Vec<PathBuf>,
#[arg(long = "no-network")]
no_network: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
TreeState {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Delta {
#[arg(long)]
from: String,
#[arg(long)]
to: String,
#[arg(long, group = "delta_mode")]
names: bool,
#[arg(long, group = "delta_mode")]
content: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Whereami {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
History {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long = "ref")]
reference: Option<String>,
#[arg(short = 'n', long, default_value_t = 20)]
limit: usize,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Ignored {
#[arg(required = true)]
paths: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Commit {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(short = 'm', long)]
message: String,
#[arg(last = true, required = true, num_args = 1..)]
paths: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Verify {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long)]
phase: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Next {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
HookCheck {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
pub(crate) enum CandidateCommand {
Create {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long, visible_alias = "target")]
label: String,
#[arg(long, default_value = "audit")]
kind: String,
#[arg(long)]
role: String,
#[arg(long)]
payload: String,
#[arg(long)]
base: String,
#[arg(long)]
source: Option<String>,
#[arg(long)]
supersedes: Option<String>,
#[arg(long)]
worktree: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Admit {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long)]
role: String,
#[arg(long)]
candidate: String,
#[arg(long)]
review: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Ingest {
#[arg(long, value_parser = parse_cli_id)]
slice: u32,
#[arg(long, visible_alias = "target")]
label: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: DispatchCommand, _color: bool) -> anyhow::Result<()> {
match cmd {
DispatchCommand::Sync {
slice,
record_integration,
integrate,
show_journal_trunk_oid,
trunk,
edge,
allow_corpus_clobber,
path,
..
} => {
if show_journal_trunk_oid {
run_show_journal_trunk_oid(path, slice, trunk.as_deref())
} else if record_integration {
run_record_integration(path, slice, trunk.as_deref())
} else if integrate {
let allow: BTreeSet<String> = allow_corpus_clobber.into_iter().collect();
run_integrate(path, slice, trunk.as_deref(), edge.as_deref(), &allow)
} else {
run_prepare_review(path, slice)
}
}
DispatchCommand::RecordBoundary {
slice,
phase,
code_start,
code_end,
path,
} => run_record_boundary(path, slice, &phase, &code_start, &code_end),
DispatchCommand::RefreshBase { slice, path } => run_refresh_base(path, slice),
DispatchCommand::Setup { slice, dir, path } => {
let claude_harness =
std::env::vars_os().any(|(k, _v)| k.to_string_lossy().starts_with("CLAUDE"));
run_setup(path, slice, &dir, claude_harness)
}
DispatchCommand::Candidate { command } => match command {
CandidateCommand::Create {
slice,
label,
kind,
role,
payload,
base,
source,
supersedes,
worktree,
path,
} => {
let req = CreateRequest {
slice,
label,
kind: parse_kind(&kind)?,
role: parse_role(&role)?,
payload: parse_payload(&payload)?,
base,
source,
supersedes,
worktree,
created_at: crate::clock::today(),
};
run_candidate_create(path, &req)
}
CandidateCommand::Status { slice, path } => run_candidate_status(path, slice),
CandidateCommand::Admit {
slice,
role,
candidate,
review,
path,
} => {
let req = AdmitRequest {
slice,
role: parse_role(&role)?,
candidate,
review,
admitted_at: crate::clock::today(),
};
run_candidate_admit(path, &req)
}
CandidateCommand::Ingest { slice, label, path } => {
let req = IngestRequest {
slice,
label,
ingested_at: crate::clock::today(),
};
run_candidate_ingest(path, &req)
}
},
DispatchCommand::PlanNext { slice, json, path } => run_plan_next(path, slice, json),
DispatchCommand::Status { slice, json, path } => run_status(path, slice, json),
DispatchCommand::DeliverTo { path } => run_deliver_to(path),
DispatchCommand::ArmSpawn {
base,
slice,
phase,
extra_rw,
no_network,
path,
} => run_arm_spawn(
path,
base.as_deref(),
slice,
phase.as_deref(),
extra_rw,
no_network,
),
DispatchCommand::TreeState { slice, path } => run_tree_state(path, slice),
DispatchCommand::Delta {
from,
to,
names,
content: _content,
path,
} => {
let mode = if names {
git::DiffMode::Names
} else {
git::DiffMode::Content
};
run_delta(path, &from, &to, mode)
}
DispatchCommand::Whereami { path } => run_whereami(path),
DispatchCommand::History {
slice,
reference,
limit,
path,
} => run_history(path, slice, reference.as_deref(), limit),
DispatchCommand::Ignored { paths, path } => run_ignored(path, &paths),
DispatchCommand::Commit {
slice,
message,
paths,
path,
} => run_commit(path, slice, &message, &paths),
DispatchCommand::Verify { slice, phase, path } => run_verify(path, slice, &phase),
DispatchCommand::Next { slice, json, path } => run_next(path, slice, json),
DispatchCommand::HookCheck { path } => run_hook_check(path),
}
}
fn run_arm_spawn(
path: Option<PathBuf>,
base: Option<&str>,
slice: Option<u32>,
phase: Option<&str>,
extra_rw: Vec<PathBuf>,
no_network: bool,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let resolved = match base {
Some(b) => b.trim().to_string(),
None => git::git_text(&root, &["rev-parse", "HEAD"])?,
};
let b = resolved.as_str();
if !(4..=64).contains(&b.len()) || !b.bytes().all(|c| c.is_ascii_hexdigit()) {
bail!("bad-base: `{b}` is not a 4..=64-char hex oid");
}
let spawn = root.join(crate::worktree::ARMING_SUBPATH);
std::fs::create_dir_all(&spawn)
.with_context(|| format!("create arming dir {}", spawn.display()))?;
crate::fsutil::write_atomic(
&spawn.join(crate::worktree::ARMING_BASE_FILE),
format!("{b}\n").as_bytes(),
)
.with_context(|| format!("write arming base in {}", spawn.display()))?;
write_arming_binding(&spawn, slice, phase)?;
write_arming_jail_policy(&spawn, extra_rw, no_network)?;
let spawn_canon = std::fs::canonicalize(&spawn)
.with_context(|| format!("canonicalize arming dir {}", spawn.display()))?;
if let Some(slice) = slice {
writeln!(io::stderr(), "armed SL-{slice:03} at base {b}")?;
}
writeln!(io::stdout(), "{}", spawn_canon.display())?;
Ok(())
}
fn write_arming_binding(
spawn: &Path,
slice: Option<u32>,
phase: Option<&str>,
) -> anyhow::Result<()> {
let write_slot = |slot: &str, value: Option<String>| -> anyhow::Result<()> {
let path = spawn.join(slot);
match value {
Some(v) => crate::fsutil::write_atomic(&path, format!("{v}\n").as_bytes())
.with_context(|| format!("write arming {slot} in {}", spawn.display())),
None => match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e)
.with_context(|| format!("clear stale arming {slot} in {}", spawn.display())),
},
}
};
write_slot(
crate::worktree::ARMING_SLICE_FILE,
slice.map(|s| s.to_string()),
)?;
write_slot(
crate::worktree::ARMING_PHASE_FILE,
phase
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_owned),
)
}
fn write_arming_jail_policy(
spawn: &Path,
extra_rw: Vec<PathBuf>,
no_network: bool,
) -> anyhow::Result<()> {
let policy = JailPolicy {
extra_rw,
network: !no_network,
};
let decl = spawn.join(crate::worktree::ARMING_JAIL_FILE);
if policy == JailPolicy::default() {
match std::fs::remove_file(&decl) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => {
Err(e).with_context(|| format!("clear stale jail declaration {}", decl.display()))
}
}
} else {
let body = toml::to_string(&policy).context("serialize arming jail policy")?;
crate::fsutil::write_atomic(&decl, body.as_bytes())
.with_context(|| format!("write jail declaration {}", decl.display()))
}
}
fn classify_coord_placement(
dir_inside_root: bool,
claude_harness: bool,
) -> Result<(), &'static str> {
if claude_harness && !dir_inside_root {
Err("coord-outside-root-under-claude")
} else {
Ok(())
}
}
fn absolutize(p: &Path) -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir().map_or_else(|_unused| p.to_path_buf(), |cwd| cwd.join(p))
}
}
pub(crate) fn run_setup(
path: Option<PathBuf>,
slice: u32,
dir: &Path,
claude_harness: bool,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let dir_inside_root = absolutize(dir).starts_with(absolutize(&root));
classify_coord_placement(dir_inside_root, claude_harness).map_err(|token| {
anyhow::anyhow!(
"{token}: coordination worktree '{}' is outside the project root '{}'. \
The Claude dispatch arm forks the Agent worktree off the Bash cwd's HEAD; \
under a cwd-confining jail a `cd` outside the root silently reverts, so the \
worker would fork `main` instead of base B. Use a path under the project \
root — convention: .dispatch/SL-{slice:03}.",
dir.display(),
root.display()
)
})?;
let slice_root = root.join(".doctrine/slice");
let plan = crate::slice::read_plan(&slice_root, slice).with_context(|| {
format!("no plan for SL-{slice:03}; run 'doctrine slice plan {slice}' first")
})?;
if plan.phases.is_empty() {
anyhow::bail!("plan for SL-{slice:03} has no phases; add phases to plan.toml first");
}
let authoring = crate::dtoml::load_doctrine_toml(&root)?
.dispatch
.authoring_branch;
let outcome = crate::worktree::coordinate(&root, slice, dir, authoring.as_deref())?;
install_coord_hook(dir)?;
let dispatch_ref = format!("{DISPATCH_REF_PREFIX}{slice:03}");
writeln!(io::stdout(), "coordination_dir={}", dir.display())?;
writeln!(io::stdout(), "base={}", outcome.dispatch_tip)?;
writeln!(io::stdout(), "slice={slice}")?;
writeln!(io::stdout(), "dispatch_ref={dispatch_ref}")?;
Ok(())
}
struct Planned {
target_ref: String,
source_oid: String,
commit_oid: String,
}
pub(crate) fn run_prepare_review(path: Option<PathBuf>, slice: u32) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
prepare_review(&root, slice)
}
pub(crate) fn run_show_journal_trunk_oid(
path: Option<PathBuf>,
slice: u32,
trunk: Option<&str>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let trunk: String = match trunk {
Some(t) => t.to_string(),
None => crate::dtoml::load_doctrine_toml(&root)?.dispatch.deliver_to,
};
let slice3 = format!("{slice:03}");
let journal = crate::ledger::read_journal_at_ref(&root, slice)?.unwrap_or_default();
let oid = journal
.rows
.iter()
.find(|r| r.target_ref == trunk)
.map(|r| r.planned_new_oid.as_str())
.with_context(|| {
format!("show-journal-trunk-oid: no journal row for {trunk} on dispatch/{slice3}")
})?;
writeln!(io::stdout(), "{oid}")?;
Ok(())
}
pub(crate) fn run_record_integration(
path: Option<PathBuf>,
slice: u32,
trunk: Option<&str>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let deliver_to = crate::dtoml::load_doctrine_toml(&root)?.dispatch.deliver_to;
let trunk: String = match trunk {
None => deliver_to,
Some(t) if t == deliver_to => t.to_string(),
Some(t) => bail!(
"record-integration: --trunk {t} does not match the close gate's delivery ref \
{deliver_to} ([dispatch] deliver_to) — a row targeting {t} would not be read at \
`slice status … done`; record onto {deliver_to} (or omit --trunk to default)"
),
};
let slice3 = format!("{slice:03}");
let coord_ref = format!("{DISPATCH_REF_PREFIX}{slice3}");
let tip = resolve_commit(&root, &coord_ref)?
.with_context(|| format!("record-integration: dispatch/{slice3} does not exist"))?;
let tip_tree = tree_of(&root, &tip)?;
let journal_path = format!(".doctrine/dispatch/{slice3}/journal.toml");
let mut journal = read_ledger::<Journal>(&root, &coord_ref, &slice3, "journal.toml")?;
let candidates = read_candidates(&root, slice)?;
if journal
.rows
.iter()
.any(|r| r.target_ref == trunk && r.status == LedgerStatus::Verified)
{
writeln!(
io::stderr(),
"record-integration: already integrated — dispatch/{slice3} carries a Verified \
trunk row for {trunk}; no-op"
)?;
return Ok(());
}
let row = plan_recorded_trunk_row(&root, &slice3, &journal, &candidates, &trunk)?;
let payload = row.planned_new_oid.clone();
if let Some(slot) = journal.rows.iter_mut().find(|r| r.target_ref == trunk) {
*slot = row;
} else {
journal.rows.push(row);
}
commit_journal(
&root,
&tip_tree,
&tip,
&journal_path,
&coord_ref,
&journal,
"journal: record-integration",
)?;
writeln!(
io::stderr(),
"record-integration: recorded Verified trunk row for {trunk} on dispatch/{slice3} \
(payload {payload})"
)?;
Ok(())
}
pub(crate) fn run_deliver_to(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let deliver_to = crate::dtoml::load_doctrine_toml(&root)?.dispatch.deliver_to;
writeln!(io::stdout(), "{deliver_to}")?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct TreeStateCore {
pub(crate) slice: u32,
pub(crate) clean: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) staged: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) tracked_dirty: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) untracked: Vec<String>,
}
pub(crate) fn tree_state_core(slice: u32, state: git::TreeState) -> TreeStateCore {
TreeStateCore {
slice,
clean: state.is_clean(),
staged: state.staged,
tracked_dirty: state.tracked_dirty,
untracked: state.untracked,
}
}
fn run_tree_state(path: Option<PathBuf>, slice: u32) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let core = tree_state_core(slice, git::tree_clean_untracked(&root)?);
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&core)?)?;
Ok(())
}
#[derive(serde::Serialize)]
struct DeltaCore {
from: String,
to: String,
mode: &'static str,
#[serde(skip_serializing_if = "Vec::is_empty")]
names: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
fn run_delta(
path: Option<PathBuf>,
from: &str,
to: &str,
mode: git::DiffMode,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let raw = git::three_dot_diff(&root, from, to, mode)?;
let core = match mode {
git::DiffMode::Names => DeltaCore {
from: from.to_owned(),
to: to.to_owned(),
mode: "names",
names: raw.lines().map(str::to_owned).collect(),
content: None,
},
git::DiffMode::Content => DeltaCore {
from: from.to_owned(),
to: to.to_owned(),
mode: "content",
names: Vec::new(),
content: Some(raw),
},
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&core)?)?;
Ok(())
}
#[derive(serde::Serialize)]
struct WhereamiCore {
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
detached: bool,
linked_worktree: bool,
role: &'static str,
}
const COORD_BRANCH_SHORT_PREFIX: &str = "dispatch/";
fn classify_worktree_role(branch: Option<&str>, linked: bool) -> &'static str {
if !linked {
return "primary";
}
match branch.and_then(|b| b.strip_prefix(COORD_BRANCH_SHORT_PREFIX)) {
Some(suffix) if !suffix.is_empty() && suffix.bytes().all(|c| c.is_ascii_digit()) => "coord",
_ => "fork",
}
}
fn run_whereami(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let branch = git::current_branch(&root)?;
let linked = crate::worktree::is_linked_worktree(&root)?;
let role = classify_worktree_role(branch.as_deref(), linked);
let core = WhereamiCore {
detached: branch.is_none(),
linked_worktree: linked,
role,
branch,
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&core)?)?;
Ok(())
}
#[derive(serde::Serialize)]
struct HistoryCore {
slice: u32,
reference: String,
limit: usize,
commits: Vec<String>,
}
fn run_history(
path: Option<PathBuf>,
slice: u32,
reference: Option<&str>,
limit: usize,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let reference = reference.map_or_else(|| dispatch_ref(slice), str::to_owned);
let commits = git::log_oneline(&root, &reference, limit)?;
let core = HistoryCore {
slice,
reference,
limit,
commits,
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&core)?)?;
Ok(())
}
#[derive(serde::Serialize)]
struct IgnoredCore {
queried: Vec<String>,
ignored: Vec<String>,
}
fn run_ignored(path: Option<PathBuf>, paths: &[String]) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let ignored = git::check_ignore(&root, paths)?;
let core = IgnoredCore {
queried: paths.to_vec(),
ignored,
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&core)?)?;
Ok(())
}
pub(crate) const ENV_ALLOWED_DELETIONS: &str = "DOCTRINE_ALLOWED_DELETIONS";
pub(crate) const ENV_ALLOW_DELETE: &str = "DOCTRINE_ALLOW_DELETE";
const REFUSE_UNDECLARED_PATH: &str = "commit-undeclared-path";
const REFUSE_UNDECLARED_DELETION: &str = "commit-undeclared-deletion";
const COORD_HOOKS_DIR: &str = "doctrine-hooks";
pub(crate) const HOOK_ASSET_KEY: &str = "git-hooks/pre-commit";
const ALLOWED_DELETIONS_SEP: char = '\n';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ChangeKind {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StagedChange {
pub(crate) kind: ChangeKind,
pub(crate) path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CommitGuardRefusal {
UndeclaredPath(Vec<String>),
UndeclaredDeletion(Vec<String>),
}
impl CommitGuardRefusal {
pub(crate) fn token(&self) -> &'static str {
match self {
CommitGuardRefusal::UndeclaredPath(_) => REFUSE_UNDECLARED_PATH,
CommitGuardRefusal::UndeclaredDeletion(_) => REFUSE_UNDECLARED_DELETION,
}
}
pub(crate) fn paths(&self) -> &[String] {
match self {
CommitGuardRefusal::UndeclaredPath(p) | CommitGuardRefusal::UndeclaredDeletion(p) => p,
}
}
}
fn path_covered(path: &str, declared: &[String]) -> bool {
declared.iter().any(|d| {
d == "."
|| d == path
|| path
.strip_prefix(d.as_str())
.is_some_and(|r| r.starts_with('/'))
})
}
pub(crate) fn classify_commit(
declared: &[String],
changes: &[StagedChange],
) -> Result<Vec<String>, CommitGuardRefusal> {
let mut undeclared: Vec<String> = changes
.iter()
.filter(|c| !path_covered(&c.path, declared))
.map(|c| c.path.clone())
.collect();
if !undeclared.is_empty() {
undeclared.sort();
undeclared.dedup();
return Err(CommitGuardRefusal::UndeclaredPath(undeclared));
}
let mut unnamed_del: Vec<String> = changes
.iter()
.filter(|c| c.kind == ChangeKind::Deleted && !declared.iter().any(|d| d == &c.path))
.map(|c| c.path.clone())
.collect();
if !unnamed_del.is_empty() {
unnamed_del.sort();
unnamed_del.dedup();
return Err(CommitGuardRefusal::UndeclaredDeletion(unnamed_del));
}
let mut dels: Vec<String> = changes
.iter()
.filter(|c| c.kind == ChangeKind::Deleted)
.map(|c| c.path.clone())
.collect();
dels.sort();
dels.dedup();
Ok(dels)
}
fn parse_name_status(out: &str) -> Vec<StagedChange> {
out.lines()
.filter_map(|line| {
let mut it = line.splitn(2, '\t');
let status = it.next()?.trim();
let path = it.next()?.trim();
let letter = status.chars().next()?;
if path.is_empty() {
return None;
}
let kind = match letter {
'A' => ChangeKind::Added,
'D' => ChangeKind::Deleted,
_ => ChangeKind::Modified,
};
Some(StagedChange {
kind,
path: path.to_string(),
})
})
.collect()
}
fn compute_declared_changes(root: &Path, declared: &[String]) -> anyhow::Result<Vec<StagedChange>> {
let git_dir = git::git_text(root, &["rev-parse", "--absolute-git-dir"])?;
let tmp = Path::new(&git_dir).join(format!("doctrine-commit-idx-{}", std::process::id()));
let run = |args: &[&str]| -> anyhow::Result<std::process::Output> {
std::process::Command::new("git")
.arg("-C")
.arg(root)
.env("GIT_INDEX_FILE", &tmp)
.args(args)
.output()
.context("git (throwaway index)")
};
let result = (|| -> anyhow::Result<Vec<StagedChange>> {
let seed = run(&["read-tree", "HEAD"])?;
if !seed.status.success() {
bail!(
"dispatch commit: seed throwaway index from HEAD: {}",
String::from_utf8_lossy(&seed.stderr).trim()
);
}
let mut add: Vec<&str> = vec!["add", "-A", "--"];
add.extend(declared.iter().map(String::as_str));
let staged = run(&add)?;
if !staged.status.success() {
bail!(
"dispatch commit: stage declared pathspec: {}",
String::from_utf8_lossy(&staged.stderr).trim()
);
}
let mut diff: Vec<&str> = vec![
"diff",
"--cached",
"--no-renames",
"--name-status",
"HEAD",
"--",
];
diff.extend(declared.iter().map(String::as_str));
let out = run(&diff)?;
if !out.status.success() {
bail!(
"dispatch commit: diff throwaway index: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(parse_name_status(&String::from_utf8_lossy(&out.stdout)))
})();
let _unused = std::fs::remove_file(&tmp); result
}
fn run_commit(
path: Option<PathBuf>,
slice: u32,
message: &str,
declared: &[String],
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let changes = compute_declared_changes(&root, declared)?;
let deletions = match classify_commit(declared, &changes) {
Ok(dels) => dels,
Err(refusal) => {
let what = match refusal {
CommitGuardRefusal::UndeclaredPath(_) => "escapes the declared pathspec",
CommitGuardRefusal::UndeclaredDeletion(_) => {
"deletes a path not EXPLICITLY named in the declared pathspec"
}
};
bail!(
"{}: for SL-{slice:03} the to-be-committed set {what}: {}. Re-run \
`dispatch commit` naming those paths explicitly; a DELIBERATE deletion \
bypasses BOTH hook arms with {}=1.",
refusal.token(),
refusal.paths().join(", "),
ENV_ALLOW_DELETE,
);
}
};
let mut add: Vec<&str> = vec!["add", "-A", "--"];
add.extend(declared.iter().map(String::as_str));
git::git_text(&root, &add).context("dispatch commit: stage declared pathspec")?;
let mut cmd = std::process::Command::new("git");
cmd.arg("-C")
.arg(&root)
.args(["commit", "-m", message, "--"]);
for d in declared {
cmd.arg(d);
}
if !deletions.is_empty() {
cmd.env(
ENV_ALLOWED_DELETIONS,
deletions.join(&ALLOWED_DELETIONS_SEP.to_string()),
);
}
let status = cmd
.status()
.context("dispatch commit: child `git commit`")?;
if !status.success() {
bail!(
"dispatch commit: the coord pre-commit hook refused, or git errored, for \
SL-{slice:03} — nothing landed. See the hook's message above."
);
}
let oid = git::git_text(&root, &["rev-parse", "HEAD"])?;
writeln!(io::stdout(), "{oid}")?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HookOutcome {
Pass,
Refuse {
deletions: Vec<String>,
reversions: Vec<String>,
},
}
fn is_funnel_reversion(base: Option<&str>, head: Option<&str>, staged: Option<&str>) -> bool {
head != base && staged.is_some() && staged == base
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FunnelRun {
candidates: Vec<String>,
base: Option<String>,
}
fn funnel_run(root: &Path, start: &str) -> anyhow::Result<FunnelRun> {
let Some(mut cur) = git::git_opt(
root,
&["rev-parse", "--verify", &format!("{start}^{{commit}}")],
)?
else {
return Ok(FunnelRun {
candidates: Vec::new(),
base: None,
});
};
let mut candidates = Vec::new();
loop {
let subject = git::git_text(root, &["log", "-1", "--format=%s", &cur])?;
candidates.push(cur.clone());
if !subject.starts_with(FUNNEL_MARKER) {
return Ok(FunnelRun {
base: Some(cur),
candidates,
});
}
match git::parents(root, &cur)?.into_iter().next() {
Some(parent) => cur = parent,
None => {
return Ok(FunnelRun {
candidates,
base: None,
});
}
}
}
}
fn funnel_run_base(root: &Path) -> anyhow::Result<Option<String>> {
Ok(funnel_run(root, "HEAD")?.base)
}
fn blob_at(root: &Path, spec: &str) -> anyhow::Result<Option<String>> {
Ok(git::git_opt(
root,
&["rev-parse", "--verify", "--quiet", spec],
)?)
}
fn evaluate_hook(
root: &Path,
allow_delete: bool,
allowed_deletions: &BTreeSet<String>,
) -> anyhow::Result<HookOutcome> {
if allow_delete {
return Ok(HookOutcome::Pass);
}
let raw = git::git_text(
root,
&["diff", "--cached", "--no-renames", "--name-status", "HEAD"],
)?;
let changes = parse_name_status(&raw);
let mut deletions: Vec<String> = changes
.iter()
.filter(|c| c.kind == ChangeKind::Deleted && !allowed_deletions.contains(&c.path))
.map(|c| c.path.clone())
.collect();
deletions.sort();
deletions.dedup();
let mut reversions: Vec<String> = Vec::new();
if let Some(base) = funnel_run_base(root)? {
for c in changes.iter().filter(|c| c.kind == ChangeKind::Modified) {
let base_b = blob_at(root, &format!("{base}:{}", c.path))?;
let head_b = blob_at(root, &format!("HEAD:{}", c.path))?;
let staged_b = blob_at(root, &format!(":{}", c.path))?;
if is_funnel_reversion(base_b.as_deref(), head_b.as_deref(), staged_b.as_deref()) {
reversions.push(c.path.clone());
}
}
}
reversions.sort();
reversions.dedup();
if deletions.is_empty() && reversions.is_empty() {
Ok(HookOutcome::Pass)
} else {
Ok(HookOutcome::Refuse {
deletions,
reversions,
})
}
}
fn run_hook_check(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let allow_delete =
std::env::var_os(ENV_ALLOW_DELETE).is_some_and(|v| v == std::ffi::OsStr::new("1"));
let allowed: BTreeSet<String> = std::env::var_os(ENV_ALLOWED_DELETIONS)
.and_then(|v| v.into_string().ok())
.map(|v| {
v.split(ALLOWED_DELETIONS_SEP)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
match evaluate_hook(&root, allow_delete, &allowed)? {
HookOutcome::Pass => Ok(()),
HookOutcome::Refuse {
deletions,
reversions,
} => {
let del_line = if deletions.is_empty() {
String::new()
} else {
format!(
"\n deletion arm — staged deletion(s): {}",
deletions.join(", ")
)
};
let rev_line = if reversions.is_empty() {
String::new()
} else {
format!(
"\n reversion arm — staged revert(s) of funnel-advanced file(s): {}",
reversions.join(", ")
)
};
bail!(
"dispatch hook-check: refusing the funnel-reversion signature \
(ISS-234).{del_line}{rev_line}\n Route authored writes through \
`dispatch commit`; a DELIBERATE act bypasses BOTH arms with {ENV_ALLOW_DELETE}=1."
);
}
}
}
fn install_coord_hook(coord: &Path) -> anyhow::Result<()> {
git::git_text(coord, &["config", "extensions.worktreeConfig", "true"])
.context("enable extensions.worktreeConfig for the coord hook")?;
let git_dir = git::git_text(coord, &["rev-parse", "--absolute-git-dir"])?;
let hooks_dir = Path::new(&git_dir).join(COORD_HOOKS_DIR);
std::fs::create_dir_all(&hooks_dir)
.with_context(|| format!("create coord hooks dir {}", hooks_dir.display()))?;
let bytes = crate::asset_source::read_bytes(HOOK_ASSET_KEY).with_context(|| {
format!("embedded hook asset '{HOOK_ASSET_KEY}' is missing (hollow binary?)")
})?;
let hook_path = hooks_dir.join("pre-commit");
crate::fsutil::write_atomic(&hook_path, bytes.as_ref())
.with_context(|| format!("write coord pre-commit hook {}", hook_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod +x {}", hook_path.display()))?;
}
git::git_text(
coord,
&[
"config",
"--worktree",
"core.hooksPath",
&hooks_dir.to_string_lossy(),
],
)
.context("set the coord worktree's core.hooksPath")?;
Ok(())
}
pub(crate) fn run_integrate(
path: Option<PathBuf>,
slice: u32,
trunk: Option<&str>,
edge: Option<&str>,
allow: &BTreeSet<String>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let cfg = crate::dtoml::load_doctrine_toml(&root)?.dispatch;
guard_not_on_integration_ref(&root, &cfg)?;
integrate(&root, slice, trunk, edge, allow)
}
fn guard_not_on_integration_ref(
root: &Path,
cfg: &crate::dispatch_config::DispatchConfig,
) -> anyhow::Result<()> {
let current = git::current_branch(root)?;
if corpus_guard::on_integration_buffer(
current.as_deref(),
cfg.authoring_branch.as_deref(),
&cfg.deliver_to,
) {
let authoring = cfg.authoring_branch.as_deref().unwrap_or_default();
let buffer = corpus_guard::short_branch_name(&cfg.deliver_to);
bail!(
"{} `{}` — the primary must stay on `{authoring}`. Restore \
(`git checkout {authoring}`) and promote via \
`git fetch . {authoring}:{buffer}`, never `checkout {buffer}`.",
corpus_guard::REFUSE_ON_TRUNK,
cfg.deliver_to,
);
}
Ok(())
}
pub(crate) fn run_record_boundary(
path: Option<PathBuf>,
slice: u32,
phase: &str,
code_start: &str,
code_end: &str,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let resolve = |refish: &str| -> anyhow::Result<String> {
resolve_commit(&root, refish)?
.with_context(|| format!("record-boundary: {refish} does not resolve to a commit"))
};
let row = crate::boundary::BoundaryRow {
phase: phase.to_string(),
code_start_oid: resolve(code_start)?,
code_end_oid: resolve(code_end)?,
provenance: crate::boundary::Provenance::Funnel,
};
let dref = dispatch_ref(slice);
let tip = resolve_commit(&root, &dref)?
.with_context(|| format!("record-boundary: {dref} does not resolve to a commit"))?;
let funnel_record = funnel::read_funnel_at(&root, &tip, slice)?;
match funnel_record.row(phase) {
Some(funnel_row) => {
match land_conclude(
&root,
&tip,
slice,
funnel_row,
row.clone(),
&crate::clock::now_timestamp()?,
)? {
funnel::FunnelLanding::Landed { .. } | funnel::FunnelLanding::Replayed { .. } => {}
funnel::FunnelLanding::Illegal(illegal) => bail!("record-boundary: {illegal}"),
funnel::FunnelLanding::CommitRefused(refusal) => {
bail!("record-boundary: {}", refusal.token())
}
}
}
None => match land_boundary_row(
&root,
&dref,
&tip,
slice,
row.clone(),
&Provenance::Conclude {
who: dispatch_identity(),
},
)? {
CommitOutcome::Landed { .. } => {}
CommitOutcome::Refused(refusal) => bail!("record-boundary: {}", refusal.token()),
},
}
crate::state::record_source_delta(&root, slice, row)
}
pub(crate) fn run_refresh_base(path: Option<PathBuf>, slice: u32) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let slice3 = format!("{slice:03}");
let dispatch_ref = format!("{DISPATCH_REF_PREFIX}{slice3}");
let trunk_tip = git::trunk_commit(&root)?.with_context(|| "trunk ref not found")?;
let coord = git::worktree_for_ref(&root, &dispatch_ref)?.with_context(|| {
format!(
"no live coordination worktree for dispatch/{slice3}; \
run 'dispatch setup --slice {slice}' (or resume) first"
)
})?;
let dispatch_tip = git::git_text(&coord, &["rev-parse", "HEAD"])?;
let dirty = git::git_text(&coord, &["status", "--porcelain"])?;
if !dirty.is_empty() {
bail!("refusing to refresh over a dirty coordination worktree (dispatch/{slice3})");
}
if git::merge_base(&coord, &dispatch_tip, &trunk_tip)?.is_none() {
bail!("unrelated histories — dispatch/{slice3} and trunk share no common ancestor");
}
if git::is_ancestor(&coord, &trunk_tip, &dispatch_tip)? {
writeln!(
io::stdout(),
"dispatch/{slice3} already fresh — trunk {} is already merged",
short(&trunk_tip)
)?;
return Ok(());
}
let msg = format!("refresh-base: merge trunk into dispatch/{slice3}");
let clean = git::git_status_ok(&coord, &["merge", "--no-ff", "-m", &msg, &trunk_tip])?;
if clean {
let new_tip = git::git_text(&coord, &["rev-parse", "HEAD"])?;
let merged = git::git_text(
&coord,
&[
"rev-list",
"--count",
&format!("{dispatch_tip}..{trunk_tip}"),
],
)?;
writeln!(
io::stdout(),
"dispatch/{slice3} refreshed: merged {merged} trunk commit(s); new tip {}",
short(&new_tip)
)?;
return Ok(());
}
let conflicts = git::git_text(&coord, &["diff", "--name-only", "--diff-filter=U"])?;
let paths: Vec<&str> = conflicts.lines().filter(|l| !l.is_empty()).collect();
bail!(
"refresh-base merge of trunk into dispatch/{slice3} conflicted in {} path(s):\n {}\n\
resolve them in the coordination worktree, then commit the merge \
(MERGE_HEAD is left in place; the dispatch ref is unadvanced).",
paths.len(),
paths.join("\n ")
);
}
fn short(oid: &str) -> &str {
oid.get(..7).unwrap_or(oid)
}
pub(crate) struct CreateRequest {
pub slice: u32,
pub label: String,
pub kind: CandidateKind,
pub role: CandidateRole,
pub payload: CandidatePayload,
pub base: String,
pub source: Option<String>,
pub supersedes: Option<String>,
pub worktree: bool,
pub created_at: String,
}
pub(crate) fn parse_kind(token: &str) -> anyhow::Result<CandidateKind> {
match token {
"audit" => Ok(CandidateKind::Audit),
"experiment" => Ok(CandidateKind::Experiment),
other => bail!("unknown candidate kind {other:?} (expected audit|experiment)"),
}
}
pub(crate) fn parse_role(token: &str) -> anyhow::Result<CandidateRole> {
match token {
"review_surface" => Ok(CandidateRole::ReviewSurface),
"close_target" => Ok(CandidateRole::CloseTarget),
"scratch" => Ok(CandidateRole::Scratch),
other => {
bail!("unknown candidate role {other:?} (expected review_surface|close_target|scratch)")
}
}
}
pub(crate) fn parse_payload(token: &str) -> anyhow::Result<CandidatePayload> {
match token {
"impl_bundle" => Ok(CandidatePayload::ImplBundle),
"code" => Ok(CandidatePayload::Code),
other => bail!("unknown candidate payload {other:?} (expected impl_bundle|code)"),
}
}
pub(crate) fn run_candidate_create(
path: Option<PathBuf>,
req: &CreateRequest,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
candidate_create(&root, req)
}
fn resolve_source_ref(req: &CreateRequest, slice3: &str) -> anyhow::Result<String> {
if let Some(src) = &req.source {
return Ok(src.clone());
}
match req.role {
CandidateRole::ReviewSurface => Ok(format!("{REVIEW_REF_PREFIX}{slice3}")),
CandidateRole::CloseTarget | CandidateRole::Scratch => bail!(
"candidate create: --source is required for a {} candidate",
role_token(req.role)
),
}
}
fn role_token(role: CandidateRole) -> &'static str {
match role {
CandidateRole::ReviewSurface => "review_surface",
CandidateRole::CloseTarget => "close_target",
CandidateRole::Scratch => "scratch",
}
}
fn is_journaled_evidence_ref(source_ref: &str, slice3: &str) -> bool {
source_ref == format!("{REVIEW_REF_PREFIX}{slice3}")
|| source_ref
.strip_prefix(&format!("{PHASE_REF_PREFIX}{slice3}-"))
.and_then(|nn| nn.parse::<u32>().ok())
.is_some()
}
const CANDIDATE_PROVENANCE_DEPTH_BUDGET: u32 = 16;
const CANDIDATE_WORKTREE_SUBPATH: &str = ".doctrine/state/dispatch/candidate";
fn is_candidate_ref(source_ref: &str) -> bool {
source_ref.starts_with(CANDIDATE_REF_PREFIX)
}
fn trace_candidate_provenance<'a>(
candidates: &'a Candidates,
journal: &Journal,
slice3: &str,
ref_name: &str,
budget: u32,
) -> anyhow::Result<&'a CandidateRow> {
if budget == 0 {
bail!(
"candidate create: provenance chain too deep or cyclic — \
budget exhausted at {ref_name}"
);
}
let mut rows = candidates.rows.iter().filter(|r| r.target_ref == ref_name);
let row = rows.next().with_context(|| {
format!("candidate create: no recorded candidate row for source {ref_name}")
})?;
if rows.next().is_some() {
bail!(
"candidate create: ambiguous candidate row for {ref_name} — \
multiple rows share the same target_ref"
);
}
anyhow::ensure!(
row.status == CandidateStatus::Created,
"candidate create: source candidate {ref_name} is {:?}, not clean (must be Created)",
row.status
);
anyhow::ensure!(
matches!(
row.role,
CandidateRole::ReviewSurface | CandidateRole::CloseTarget
) && row.kind == CandidateKind::Audit,
"candidate create: source candidate {ref_name} is role={:?}/kind={:?} — \
only an audit review_surface (or chained close_target) may source a close_target",
row.role,
row.kind
);
let next = &row.source_ref;
if is_journaled_evidence_ref(next, slice3) {
let jrow = journal
.rows
.iter()
.find(|r| r.target_ref == *next)
.with_context(|| {
format!(
"candidate create: no prepare-review journal row for source {next} — \
run `dispatch sync --prepare-review` first"
)
})?;
anyhow::ensure!(
jrow.status == LedgerStatus::Verified,
"candidate create: source {next} is not verified (status {:?}) — \
no verified evidence to build a candidate from",
jrow.status
);
let prefix = format!("{PHASE_REF_PREFIX}{slice3}-");
if let Some(nn) = next
.strip_prefix(&prefix)
.and_then(|nn| nn.parse::<u32>().ok())
{
for r in &journal.rows {
if let Some(other) = r
.target_ref
.strip_prefix(&prefix)
.and_then(|n| n.parse::<u32>().ok())
&& other < nn
&& r.status == LedgerStatus::Failed
{
bail!(
"candidate create: an earlier phase row {} failed — the phase chain \
below {next} has an unresolved hole",
r.target_ref
);
}
}
}
Ok(row)
} else if is_candidate_ref(next) {
trace_candidate_provenance(candidates, journal, slice3, next, budget - 1)
} else {
bail!(
"candidate create: source candidate built from non-evidence {next} — \
the recorded chain must terminate at a journaled evidence ref"
)
}
}
fn check_provenance<'a>(
journal: &Journal,
candidates: &'a Candidates,
slice3: &str,
role: CandidateRole,
source_ref: &str,
) -> anyhow::Result<Option<&'a CandidateRow>> {
if is_journaled_evidence_ref(source_ref, slice3) {
let row = journal
.rows
.iter()
.find(|r| r.target_ref == source_ref)
.with_context(|| {
format!(
"candidate create: no prepare-review journal row for source {source_ref} — \
run `dispatch sync --prepare-review` first"
)
})?;
anyhow::ensure!(
row.status == LedgerStatus::Verified,
"candidate create: source {source_ref} is not verified (status {:?}) — \
no verified evidence to build a candidate from",
row.status
);
let prefix = format!("{PHASE_REF_PREFIX}{slice3}-");
if let Some(nn) = source_ref
.strip_prefix(&prefix)
.and_then(|nn| nn.parse::<u32>().ok())
{
for r in &journal.rows {
if let Some(other) = r
.target_ref
.strip_prefix(&prefix)
.and_then(|n| n.parse::<u32>().ok())
&& other < nn
&& r.status == LedgerStatus::Failed
{
bail!(
"candidate create: an earlier phase row {} failed — the phase chain \
below {source_ref} has an unresolved hole",
r.target_ref
);
}
}
}
Ok(None)
} else if role == CandidateRole::CloseTarget && is_candidate_ref(source_ref) {
let row = trace_candidate_provenance(
candidates,
journal,
slice3,
source_ref,
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
)?;
Ok(Some(row))
} else {
bail!(
"candidate create: no prepare-review journal row for source {source_ref} — \
run `dispatch sync --prepare-review` first"
)
}
}
fn candidate_conflict_message(source_ref: &str, base: &str, ahead: u32) -> String {
let hint = if ahead > 0 {
format!(
"; trunk has advanced {ahead} commit(s) past this source — \
the conflict may be base divergence; try `dispatch refresh-base` \
then re-prepare + re-create"
)
} else {
String::new()
};
format!(
"candidate create: 3-way merge of {source_ref} onto {base} conflicts — \
pass --worktree to park the candidate branch at the base for \
manual resolve+commit, or abort (no row/ref/worktree written){hint}"
)
}
fn candidate_create(root: &Path, req: &CreateRequest) -> anyhow::Result<()> {
let slice3 = format!("{:03}", req.slice);
let coord_ref = format!("{DISPATCH_REF_PREFIX}{slice3}");
let target_ref = format!("{CANDIDATE_REF_PREFIX}{slice3}/{}", req.label);
let id = format!("cand-{slice3}-{}", req.label);
if let Some(branch) = git::current_branch(root)?
&& is_raw_evidence_ref(&branch)
{
bail!(
"candidate create: the current worktree is checked out on raw evidence ref {branch:?} \
(review/* and phase/* are immutable, invariant I9) — never edit it in place; \
run `dispatch candidate create` from a safe branch (e.g. the coordination tree) \
to publish a candidate instead"
);
}
if req.role == CandidateRole::ReviewSurface && !req.worktree {
bail!(
"candidate create: a review_surface candidate requires an explicit --worktree \
(v1: the review surface is always materialised for the reviewer to read)"
);
}
let source_ref = resolve_source_ref(req, &slice3)?;
let journal = read_ledger::<Journal>(root, &coord_ref, &slice3, "journal.toml")?;
let mut ledger = read_candidates(root, req.slice)?;
let matched_row = check_provenance(&journal, &ledger, &slice3, req.role, &source_ref)?;
let source_oid = resolve_commit(root, &source_ref)?
.with_context(|| format!("candidate create: source {source_ref} does not resolve"))?;
let base_oid = resolve_commit(root, &req.base)?
.with_context(|| format!("candidate create: base {} does not resolve", req.base))?;
if let Some(row) = matched_row {
anyhow::ensure!(
!row.merge_oid.is_empty(),
"candidate create: source candidate {source_ref} has an empty merge_oid — cannot verify lineage"
);
anyhow::ensure!(
git::is_ancestor(root, &row.merge_oid, &source_oid)?,
"candidate create: source candidate {} tip {} does not descend from its recorded \
merge {} — the ref moved off its provenance lineage",
source_ref,
source_oid,
row.merge_oid
);
}
let supersedes = match &req.supersedes {
Some(prior) => {
anyhow::ensure!(
ledger.rows.iter().any(|r| r.id == *prior),
"candidate create: --supersedes {prior} names no recorded candidate"
);
prior.clone()
}
None => String::new(),
};
let merge_base = git::merge_base(root, &base_oid, &source_oid)?.with_context(|| {
format!(
"candidate create: base {base_oid} and source {source_oid} share no common ancestor"
)
})?;
let (branch_oid, merge_oid, status) =
match git::merge_tree(root, &merge_base, &base_oid, &source_oid)? {
MergeTree::Clean { tree } => {
let merge_oid = git::commit_tree_merge(
root,
&tree,
&base_oid,
&source_oid,
&format!("candidate({slice3}/{}): merge {source_ref}", req.label),
)?;
(merge_oid.clone(), merge_oid, CandidateStatus::Created)
}
MergeTree::Conflict { .. } if !req.worktree => {
let ahead = trunk_drift(root, &source_oid)?.map_or(0, |d| d.ahead);
bail!(candidate_conflict_message(&source_ref, &req.base, ahead))
}
MergeTree::Conflict { tree, stages } => {
return create_conflict_worktree(
root,
req,
&slice3,
&id,
&target_ref,
&base_oid,
&source_oid,
&source_ref,
&tree,
&stages,
ledger,
supersedes,
);
}
};
match git::update_ref_cas(root, &target_ref, &branch_oid, ZERO_OID)? {
RefCas::Updated => {}
RefCas::Moved { actual } => bail!(
"candidate create: {target_ref} already exists (at {}) — \
supersede creates a fresh label, never rewrites a branch",
actual.as_deref().unwrap_or("?")
),
}
let worktree_path = if req.worktree {
match add_candidate_worktree(root, &id, &target_ref, false) {
Ok(path) => {
if let Err(e) = run_provision(Some(root.to_path_buf()), &path) {
rollback_ref(root, &target_ref, &branch_oid);
return Err(e.context("provision candidate worktree"));
}
Some(path)
}
Err(e) => {
rollback_ref(root, &target_ref, &branch_oid);
return Err(e);
}
}
} else {
None
};
let row = CandidateRow {
id: id.clone(),
label: req.label.clone(),
kind: req.kind,
role: req.role,
payload: req.payload,
target_ref: target_ref.clone(),
source_ref,
source_oid,
base_ref: req.base.clone(),
base_oid,
merge_oid: merge_oid.clone(),
status,
supersedes,
reason: String::new(),
created_by: "dispatch candidate create".to_owned(),
created_at: req.created_at.clone(),
ingested_at: String::new(),
merge_provenance: crate::ledger::MergeProvenance::Doctrine,
};
ledger.rows.push(row);
crate::ledger::write_candidates(root, req.slice, &ledger)?;
writeln!(io::stdout(), "{target_ref}")?;
if let Some(path) = &worktree_path {
writeln!(io::stdout(), "{}", path.display())?;
}
match status {
CandidateStatus::Conflicted => writeln!(
io::stderr(),
"candidate create: {id} conflicted — branch parked at base {branch_oid}; \
resolve+commit in {}",
worktree_path
.as_ref()
.map_or_else(|| "(worktree)".to_owned(), |p| p.display().to_string())
)?,
_ => writeln!(
io::stderr(),
"candidate create: {id} created at {merge_oid}"
)?,
}
Ok(())
}
fn add_candidate_worktree(
root: &Path,
id: &str,
target_ref: &str,
on_branch: bool,
) -> anyhow::Result<PathBuf> {
let wt_path = root.join(CANDIDATE_WORKTREE_SUBPATH).join(id);
if let Some(parent) = wt_path.parent() {
std::fs::create_dir_all(parent)?;
}
let wt_str = wt_path
.to_str()
.context("candidate create: worktree path is not valid UTF-8")?;
let checkout = if on_branch {
target_ref.strip_prefix("refs/heads/").unwrap_or(target_ref)
} else {
target_ref
};
git::git_text(root, &["worktree", "add", "--quiet", wt_str, checkout])?;
Ok(wt_path)
}
fn rollback_ref(root: &Path, target_ref: &str, expected: &str) {
let _ignored = git::git_opt(root, &["update-ref", "-d", target_ref, expected]);
}
fn conflict_reproducibility_guards(
root: &Path,
base_oid: &str,
source_oid: &str,
tc: &str,
verb: &str,
) -> anyhow::Result<String> {
let bases = git::merge_base_all(root, base_oid, source_oid)?;
anyhow::ensure!(
bases.len() == 1,
"{verb}: base {base_oid} and source {source_oid} have {} merge bases \
(criss-cross) — a hand-resolved ingest needs a single 3-way base; the conflict \
set would be ambiguous. Resolve via a different route.",
bases.len()
);
let custom = git::custom_merge_driver_paths(root, tc)?;
if let Some(path) = custom.first() {
bail!(
"{verb}: path {} carries a custom (non-built-in) merge driver — the \
conflict set is not reproducible, so a hand-resolved ingest cannot be validated \
(design §5.4/D8). Only built-in drivers (union/binary/…) are allowed.",
String::from_utf8_lossy(path)
);
}
bases.into_iter().next().ok_or_else(|| {
anyhow::anyhow!("{verb}: merge-base --all returned no base after a len==1 check")
})
}
#[expect(
clippy::too_many_arguments,
reason = "the resolved conflict-create inputs (req + computed slice3/id/target_ref/oids + \
ledger); a single-use params struct would only relocate the same fields"
)]
fn create_conflict_worktree(
root: &Path,
req: &CreateRequest,
slice3: &str,
id: &str,
target_ref: &str,
base_oid: &str,
source_oid: &str,
source_ref: &str,
tc: &str,
stages: &[git::ConflictStage],
mut ledger: Candidates,
supersedes: String,
) -> anyhow::Result<()> {
let _base =
conflict_reproducibility_guards(root, base_oid, source_oid, tc, "candidate create")?;
match git::update_ref_cas(root, target_ref, base_oid, ZERO_OID)? {
RefCas::Updated => {}
RefCas::Moved { actual } => bail!(
"candidate create: {target_ref} already exists (at {}) — \
supersede creates a fresh label, never rewrites a branch",
actual.as_deref().unwrap_or("?")
),
}
let row = CandidateRow {
id: id.to_owned(),
label: req.label.clone(),
kind: req.kind,
role: req.role,
payload: req.payload,
target_ref: target_ref.to_owned(),
source_ref: source_ref.to_owned(),
source_oid: source_oid.to_owned(),
base_ref: req.base.clone(),
base_oid: base_oid.to_owned(),
merge_oid: String::new(),
status: CandidateStatus::Conflicted,
supersedes,
reason: String::new(),
created_by: "dispatch candidate create".to_owned(),
created_at: req.created_at.clone(),
ingested_at: String::new(),
merge_provenance: crate::ledger::MergeProvenance::Doctrine,
};
ledger.rows.push(row);
if let Err(e) = crate::ledger::write_candidates(root, req.slice, &ledger) {
rollback_ref(root, target_ref, base_oid);
return Err(e.context("candidate create: record conflicted candidate row"));
}
let merge_msg = format!("candidate({slice3}/{}): merge {source_ref}\n", req.label);
let provision_and_materialise = || -> anyhow::Result<PathBuf> {
let path = add_candidate_worktree(root, id, target_ref, true)?;
run_provision(Some(root.to_path_buf()), &path).context("provision candidate worktree")?;
git::materialise_conflict_worktree(&path, tc, stages, source_oid, &merge_msg)?;
Ok(path)
};
let worktree_path = match provision_and_materialise() {
Ok(path) => path,
Err(e) => {
rollback_conflict_worktree(root, id, req.slice, target_ref, base_oid, &ledger);
return Err(e);
}
};
writeln!(io::stdout(), "{target_ref}")?;
writeln!(io::stdout(), "{}", worktree_path.display())?;
writeln!(
io::stderr(),
"candidate create: {id} conflicted — resolve the markers and `git commit` in {}, \
then `dispatch candidate ingest` from the coordination tree",
worktree_path.display()
)?;
Ok(())
}
fn rollback_conflict_worktree(
root: &Path,
id: &str,
slice: u32,
target_ref: &str,
base_oid: &str,
ledger: &Candidates,
) {
let wt_path = root.join(CANDIDATE_WORKTREE_SUBPATH).join(id);
if let Some(wt) = wt_path.to_str() {
let _ignored = git::git_opt(root, &["worktree", "remove", "--force", wt]);
}
let mut without_row = ledger.clone();
without_row.rows.retain(|r| r.id != id);
let _ignored = crate::ledger::write_candidates(root, slice, &without_row);
rollback_ref(root, target_ref, base_oid);
}
fn is_raw_evidence_ref(branch: &str) -> bool {
branch.starts_with("review/") || branch.starts_with("phase/")
}
pub(crate) struct AdmitRequest {
pub slice: u32,
pub role: CandidateRole,
pub candidate: String,
pub review: Option<String>,
pub admitted_at: String,
}
pub(crate) struct IngestRequest {
pub slice: u32,
pub label: String,
pub ingested_at: String,
}
pub(crate) struct IngestReject {
pub reason: String,
}
pub(crate) fn validate_ingest_provenance(
parents: &[String],
base_oid: &str,
source_oid: &str,
diff_from_mechanical: &BTreeSet<Vec<u8>>,
conflict_paths: &BTreeSet<Vec<u8>>,
marker_paths: &[Vec<u8>],
) -> Result<(), IngestReject> {
if !matches!(parents, [p0, p1] if p0.as_str() == base_oid && p1.as_str() == source_oid) {
return Err(IngestReject {
reason: format!(
"parents must be [base, source] = [{base_oid}, {source_oid}] in order, got {parents:?}"
),
});
}
if let Some(stray) = diff_from_mechanical
.iter()
.find(|p| !conflict_paths.contains(*p))
{
return Err(IngestReject {
reason: format!(
"resolved tree edits a non-conflict path {} — not a faithful (base, source) merge",
String::from_utf8_lossy(stray)
),
});
}
if let Some(marked) = marker_paths.first() {
return Err(IngestReject {
reason: format!(
"conflict markers still present at {} — resolve before ingest",
String::from_utf8_lossy(marked)
),
});
}
Ok(())
}
pub(crate) fn run_candidate_ingest(
path: Option<PathBuf>,
req: &IngestRequest,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
candidate_ingest(&root, req)
}
fn candidate_ingest(root: &Path, req: &IngestRequest) -> anyhow::Result<()> {
let resolved = std::fs::canonicalize(root).unwrap_or_else(|_ignored| root.to_path_buf());
if resolved
.to_string_lossy()
.contains(CANDIDATE_WORKTREE_SUBPATH)
{
bail!(
"candidate ingest: the current worktree is a candidate checkout ({}) — \
resolve the conflicts and `git commit` here, then run `dispatch candidate ingest` \
from the coordination tree (the ledger resolves at the coordination root, never \
this candidate checkout's stale tree)",
resolved.display()
);
}
let slice3 = format!("{:03}", req.slice);
let target_ref = format!("{CANDIDATE_REF_PREFIX}{slice3}/{}", req.label);
let mut ledger = read_candidates(root, req.slice)?;
let matched: Vec<usize> = ledger
.rows
.iter()
.enumerate()
.filter(|(_, r)| {
r.label == req.label
&& r.status == CandidateStatus::Conflicted
&& r.merge_oid.is_empty()
})
.map(|(i, _)| i)
.collect();
let idx = match matched.as_slice() {
[only] => *only,
[] => bail!(
"candidate ingest: no un-ingested conflicted candidate for label {} — an already \
ingested (Created) row cannot be re-ingested (write-once)",
req.label
),
many => bail!(
"candidate ingest: {} candidates match label {} in the conflicted pre-state — \
ambiguous; the write-once gate needs exactly one",
many.len(),
req.label
),
};
let row = ledger
.rows
.get(idx)
.cloned()
.with_context(|| "candidate ingest: internal — selected row index out of range")?;
let base_oid = row.base_oid.clone();
let source_oid = row.source_oid.clone();
let r = resolve_commit(root, &target_ref)?.with_context(|| {
format!("candidate ingest: {target_ref} does not resolve to a committed tip")
})?;
anyhow::ensure!(
r != base_oid,
"candidate ingest: {target_ref} still points at base {base_oid} — resolve the \
conflicts and `git commit` in the candidate worktree before ingesting"
);
let mb = git::merge_base(root, &base_oid, &source_oid)?.with_context(|| {
format!("candidate ingest: base {base_oid} and source {source_oid} share no ancestor")
})?;
let (tc, stages) = match git::merge_tree(root, &mb, &base_oid, &source_oid)? {
git::MergeTree::Conflict { tree, stages } => (tree, stages),
git::MergeTree::Clean { .. } => bail!(
"candidate ingest: the recorded conflict no longer reproduces (merge is now clean) — \
the base/source refs moved under the row; refusing to ingest against a changed merge"
),
};
anyhow::ensure!(
!stages.is_empty(),
"candidate ingest: merge-tree reported a conflict with an empty conflict set — corruption"
);
let _mb =
conflict_reproducibility_guards(root, &base_oid, &source_oid, &tc, "candidate ingest")?;
let conflict_paths: BTreeSet<Vec<u8>> = stages.iter().map(|s| s.path.clone()).collect();
let r_tree = tree_of(root, &r)?;
let diff_from_mechanical = git::changed_paths(root, &r_tree, &tc)?;
let marker_paths = git::surviving_marker_paths(root, &r_tree, &conflict_paths);
if let Err(reject) = validate_ingest_provenance(
&git::parents(root, &r)?,
&base_oid,
&source_oid,
&diff_from_mechanical,
&conflict_paths,
&marker_paths,
) {
bail!("candidate ingest: {}", reject.reason);
}
let r2 = resolve_commit(root, &target_ref)?;
anyhow::ensure!(
r2.as_deref() == Some(r.as_str()),
"candidate ingest: {target_ref} moved during ingest (was {r}, now {}) — re-run",
r2.as_deref().unwrap_or("absent")
);
let filled = ledger
.rows
.get_mut(idx)
.with_context(|| "candidate ingest: internal — selected row index out of range")?;
filled.merge_oid.clone_from(&r);
filled.status = CandidateStatus::Created;
filled.ingested_at.clone_from(&req.ingested_at);
filled.merge_provenance = crate::ledger::MergeProvenance::OperatorIngest;
crate::ledger::write_candidates(root, req.slice, &ledger)?;
writeln!(io::stdout(), "{r}")?;
writeln!(
io::stderr(),
"candidate ingest: {} ingested at {r} (operator merge) — admit then integrate",
row.id
)?;
Ok(())
}
pub(crate) fn run_candidate_admit(path: Option<PathBuf>, req: &AdmitRequest) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
candidate_admit(&root, req)
}
fn candidate_admit(root: &Path, req: &AdmitRequest) -> anyhow::Result<()> {
if let Some(branch) = git::current_branch(root)?
&& is_raw_evidence_ref(&branch)
{
bail!(
"candidate admit: the current worktree is checked out on raw evidence ref {branch:?} \
(review/* and phase/* are immutable, invariant I9) — never edit it in place; \
run `dispatch candidate admit` from a safe branch (e.g. the coordination tree)"
);
}
if req.role == CandidateRole::Scratch {
bail!("candidate admit: a scratch candidate is not admissible (no review/close target)");
}
let admitted_1 = resolve_commit(root, &req.candidate)?.with_context(|| {
format!(
"candidate admit: candidate {} does not resolve to a committed tip",
req.candidate
)
})?;
let mut ledger = read_candidates(root, req.slice)?;
let row = ledger
.rows
.iter()
.find(|r| r.target_ref == req.candidate)
.with_context(|| {
format!(
"candidate admit: no recorded candidate at {} — admit pins a recorded candidate",
req.candidate
)
})?
.clone();
anyhow::ensure!(
row.role == req.role,
"candidate admit: candidate {} is role {}, cannot admit as {}",
row.id,
role_token(row.role),
role_token(req.role)
);
anyhow::ensure!(
!row.merge_oid.is_empty(),
"candidate admit: candidate {} has no merge to validate \
(conflicted/unresolved) — resolve by hand and `dispatch candidate ingest` \
(or re-create) before admitting",
row.id
);
let merge_parents: std::collections::BTreeSet<String> =
git::parents(root, &row.merge_oid)?.into_iter().collect();
let expected_parents: std::collections::BTreeSet<String> =
[row.base_oid.clone(), row.source_oid.clone()]
.into_iter()
.collect();
anyhow::ensure!(
merge_parents == expected_parents,
"candidate admit: merge_oid {} is not a genuine candidate merge \
(parents are not {{base, source}})",
row.merge_oid
);
anyhow::ensure!(
git::is_ancestor(root, &row.merge_oid, &admitted_1)?,
"candidate admit: admitted tip {admitted_1} does not descend from candidate merge {} (I3)",
row.merge_oid
);
let admitted_2 = resolve_commit(root, &req.candidate)?;
anyhow::ensure!(
admitted_2.as_deref() == Some(admitted_1.as_str()),
"candidate admit: candidate {} moved during admission (was {admitted_1}, now {}) — \
re-run admit",
req.candidate,
admitted_2.as_deref().unwrap_or("absent")
);
let supersedes = prior_admission(&ledger, req.role)
.map(|a| a.candidate_id.clone())
.unwrap_or_default();
let admission = Admission {
candidate_id: row.id.clone(),
candidate_ref: req.candidate.clone(),
expected_ref_oid: admitted_1.clone(),
admitted_oid: admitted_1.clone(),
review: req.review.clone().unwrap_or_default(),
supersedes,
admitted_at: req.admitted_at.clone(),
};
let slot = match req.role {
CandidateRole::ReviewSurface => &mut ledger.current_admission.review_surface,
CandidateRole::CloseTarget | CandidateRole::Scratch => {
&mut ledger.current_admission.close_target
}
};
*slot = Some(admission);
crate::ledger::write_candidates(root, req.slice, &ledger)?;
writeln!(io::stdout(), "{admitted_1}")?;
writeln!(
io::stderr(),
"candidate admit: {} admitted at {admitted_1} ({})",
row.id,
role_token(req.role)
)?;
Ok(())
}
fn prior_admission(ledger: &Candidates, role: CandidateRole) -> Option<&Admission> {
match role {
CandidateRole::CloseTarget => ledger.current_admission.close_target.as_ref(),
CandidateRole::ReviewSurface => ledger.current_admission.review_surface.as_ref(),
CandidateRole::Scratch => None,
}
}
pub(crate) fn run_candidate_status(path: Option<PathBuf>, slice: u32) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
candidate_status(&root, slice)
}
fn short_oid(oid: &str) -> String {
if oid.is_empty() || oid == "—" {
return oid.to_owned();
}
oid.chars().take(12).collect()
}
struct EvidenceRow {
refname: String,
group: &'static str,
tip: String,
}
fn candidate_status(root: &Path, slice: u32) -> anyhow::Result<()> {
let slice3 = format!("{slice:03}");
if let Some(branch) = git::current_branch(root)?
&& is_raw_evidence_ref(&branch)
{
writeln!(
io::stderr(),
"candidate status: the current worktree is checked out on raw evidence ref `{branch}` \
(review/* and phase/* are immutable) — status is read-only and changes nothing, but \
never edit an evidence ref in place; publish via `dispatch candidate create`"
)?;
}
let ledger = read_candidates(root, slice)?;
let evidence = collect_evidence(root, &slice3)?;
let mut grid: Vec<Vec<String>> = vec![cells(&["ref", "group", "tip"])];
for row in &evidence {
grid.push(cells(&[&row.refname, row.group, &short_oid(&row.tip)]));
}
writeln!(io::stdout(), "evidence refs:")?;
write!(io::stdout(), "{}", render_table(&grid, None))?;
writeln!(io::stdout(), "\ncandidates (interaction branches):")?;
let mut cgrid: Vec<Vec<String>> = vec![cells(&[
"id",
"branch",
"status",
"base",
"source",
"tip",
"admission",
"drift",
])];
let mut any_drift = false;
for row in &ledger.rows {
let report = candidate_report(root, &ledger, row)?;
any_drift |= report.drift;
cgrid.push(cells(&[
&row.id,
&row.target_ref,
status_token(row.status),
&short_oid(&row.base_oid),
&short_oid(&row.source_oid),
&short_oid(&report.tip),
&report.admission,
if report.drift { "DRIFT" } else { "ok" },
]));
}
if ledger.rows.is_empty() {
writeln!(io::stdout(), "(none recorded)")?;
} else {
write!(io::stdout(), "{}", render_table(&cgrid, None))?;
}
write_next_commands(&slice3, &ledger, any_drift)?;
Ok(())
}
struct CandidateReport {
tip: String,
admission: String,
drift: bool,
}
fn candidate_report(
root: &Path,
ledger: &Candidates,
row: &CandidateRow,
) -> anyhow::Result<CandidateReport> {
let tip = resolve_commit(root, &row.target_ref)?.unwrap_or_else(|| "—".to_owned());
let admitted = admission_for(ledger, &row.id);
let admission = match admitted {
Some(a) => format!("admitted ({})", a.review),
None => "—".to_owned(),
};
let pinned = match admitted {
Some(a) => Some(a.admitted_oid.as_str()),
None if row.status == CandidateStatus::Conflicted => None,
None if row.merge_oid.is_empty() => None,
None => Some(row.merge_oid.as_str()),
};
let drift = match (pinned, tip.as_str()) {
(Some(pin), live) => live != "—" && live != pin,
(None, _) => false,
};
Ok(CandidateReport {
tip,
admission,
drift,
})
}
fn admission_for<'a>(ledger: &'a Candidates, id: &str) -> Option<&'a Admission> {
[
ledger.current_admission.close_target.as_ref(),
ledger.current_admission.review_surface.as_ref(),
]
.into_iter()
.flatten()
.find(|a| a.candidate_id == id)
}
fn collect_evidence(root: &Path, slice3: &str) -> anyhow::Result<Vec<EvidenceRow>> {
let mut rows: Vec<EvidenceRow> = Vec::new();
for (refname, group) in [
(format!("{DISPATCH_REF_PREFIX}{slice3}"), "coordination"),
(format!("{REVIEW_REF_PREFIX}{slice3}"), "impl-bundle"),
] {
let tip = resolve_commit(root, &refname)?.unwrap_or_else(|| "—".to_owned());
rows.push(EvidenceRow {
refname,
group,
tip,
});
}
for refname in for_each_ref(root, &format!("{PHASE_REF_PREFIX}{slice3}-*"))? {
let tip = resolve_commit(root, &refname)?.unwrap_or_else(|| "—".to_owned());
rows.push(EvidenceRow {
refname,
group: "phase-cut",
tip,
});
}
Ok(rows)
}
fn for_each_ref(root: &Path, pattern: &str) -> anyhow::Result<Vec<String>> {
let out = git::git_text(root, &["for-each-ref", "--format=%(refname)", pattern])?;
Ok(out.lines().map(str::to_owned).collect())
}
fn status_token(status: CandidateStatus) -> &'static str {
match status {
CandidateStatus::Created => "created",
CandidateStatus::Conflicted => "conflicted",
CandidateStatus::Abandoned => "abandoned",
CandidateStatus::Superseded => "superseded",
}
}
fn cells(values: &[&str]) -> Vec<String> {
values.iter().map(|s| (*s).to_string()).collect()
}
fn next_command_lines(slice3: &str, ledger: &Candidates, any_drift: bool) -> Vec<String> {
let slice = slice3.trim_start_matches('0');
let slice = if slice.is_empty() { "0" } else { slice };
if ledger.rows.is_empty() {
return vec![format!(
"dispatch candidate create --slice {slice} --role review_surface \
--payload impl_bundle --base refs/heads/main --label review-001 --worktree"
)];
}
let mut lines = vec![
format!("dispatch candidate create --slice {slice} ... # publish a fresh candidate"),
format!(
"dispatch candidate admit --slice {slice} --id <candidate-id> --review RV-NNN \
# pin a candidate for review/close"
),
];
for row in &ledger.rows {
if row.status == CandidateStatus::Conflicted && row.merge_oid.is_empty() {
lines.push(format!(
"dispatch candidate ingest --slice {slice} --label {} \
# adopt a hand-resolved merge for this conflicted candidate",
row.label
));
}
}
if any_drift {
lines.push(
"note: a DRIFTED candidate's live tip moved off its recorded/admitted oid \
(immutable) — supersede with a fresh candidate rather than editing in place"
.to_owned(),
);
}
lines
}
fn write_next_commands(slice3: &str, ledger: &Candidates, any_drift: bool) -> anyhow::Result<()> {
writeln!(io::stdout(), "\nnext:")?;
for line in next_command_lines(slice3, ledger, any_drift) {
writeln!(io::stdout(), " {line}")?;
}
Ok(())
}
fn resolve_commit(root: &Path, refish: &str) -> anyhow::Result<Option<String>> {
Ok(git::git_opt(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{refish}^{{commit}}"),
],
)?)
}
fn tree_of(root: &Path, commit: &str) -> anyhow::Result<String> {
Ok(git::git_text(
root,
&["rev-parse", &format!("{commit}^{{tree}}")],
)?)
}
fn missing_committed_funnel_phases<'a>(
registry: &'a [BoundaryRow],
committed: &BTreeSet<&str>,
) -> Vec<&'a str> {
registry
.iter()
.filter(|r| {
matches!(
r.provenance,
BoundaryProvenance::Funnel | BoundaryProvenance::Unknown
)
})
.map(|r| r.phase.as_str())
.filter(|p| !committed.contains(p))
.collect()
}
fn prepare_review(root: &Path, slice: u32) -> anyhow::Result<()> {
let slice3 = format!("{slice:03}");
let coord_ref = format!("{DISPATCH_REF_PREFIX}{slice3}");
let journal_path = format!(".doctrine/dispatch/{slice3}/journal.toml");
let tip0 = resolve_commit(root, &coord_ref)?
.with_context(|| format!("prepare-review: dispatch/{slice3} does not exist"))?;
let tip = tip0;
let tip_tree = tree_of(root, &tip)?;
let trunk_tip = git::trunk_commit(root)?
.context("prepare-review: no trunk ref resolves — a trunk base is required")?;
let trunk_base = git::merge_base(root, &tip, &trunk_tip)?.with_context(|| {
format!(
"prepare-review: dispatch/{slice3} and trunk ({trunk_tip}) share no common ancestor"
)
})?;
let orthogonal = read_ledger::<Orthogonal>(root, &coord_ref, &slice3, "orthogonal.toml")?;
let boundaries = read_ledger::<Boundaries>(root, &coord_ref, &slice3, "boundaries.toml")?;
let primary = git::primary_worktree(root)?;
let registry = crate::state::read_source_deltas(&primary, slice)?;
let committed: BTreeSet<&str> = boundaries.rows.iter().map(|r| r.phase.as_str()).collect();
let missing = missing_committed_funnel_phases(®istry, &committed);
if !missing.is_empty() {
bail!(
"prepare-review: committed boundaries ledger is missing phase(s) {missing:?} on \
dispatch/{slice3} that the registry records as funnel-owned (or legacy/unclassified). \
The registry has them but the dispatch ref does not — the coordination worktree was \
likely removed before prepare-review, or these are pre-provenance rows. Re-run with \
the coord worktree present (it persists until integrate), or record-delta + COMMIT \
the ledger for the named phase(s)."
);
}
for row in &boundaries.rows {
crate::state::record_source_delta(&primary, slice, row.clone())?;
}
if let crate::state::Completeness::Incomplete { gaps } =
crate::state::registry_completeness(&primary, &primary, slice)?
{
let detail = gaps
.iter()
.map(crate::state::CompletenessGap::describe)
.collect::<Vec<_>>()
.join("; ");
bail!(
"prepare-review: conformance registry incomplete: {detail}; \
record-delta the missing phase(s) before audit"
);
}
let mut planned: Vec<Planned> = Vec::new();
plan_review(
root,
&slice3,
&tip,
&tip_tree,
&trunk_base,
&orthogonal,
&mut planned,
)?;
plan_phases(root, &slice3, &trunk_base, &boundaries, &mut planned)?;
let mut journal = pending_journal(&planned);
let outcomes = with_journaled_projection(
root,
&tip,
&tip_tree,
&journal_path,
&coord_ref,
&mut journal,
"journal: prepare-review",
|root, row| match git::update_ref_cas(
root,
&row.target_ref,
&row.planned_new_oid,
ZERO_OID,
)? {
RefCas::Updated => {
row.status = LedgerStatus::Verified;
row.applied_new_oid = row.planned_new_oid.clone();
writeln!(io::stdout(), "{}", row.target_ref)?;
Ok(RowOutcome::Done {
disposition: Disposition::Created,
})
}
RefCas::Moved { actual } => {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!(
"{} (exists at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
),
})
}
},
)?;
let stale: Vec<String> = outcomes
.into_iter()
.filter_map(|o| match o {
RowOutcome::Refused { token } => Some(token),
RowOutcome::Done { .. } => None,
})
.collect();
if stale.is_empty() {
writeln!(
io::stderr(),
"prepare-review: {} ref(s) created",
journal.rows.len()
)?;
Ok(())
} else {
bail!(
"prepare-review: {} stale ref(s) reported, not clobbered: {}",
stale.len(),
stale.join(", ")
)
}
}
fn integrate(
root: &Path,
slice: u32,
trunk: Option<&str>,
edge: Option<&str>,
allow: &BTreeSet<String>,
) -> anyhow::Result<()> {
let slice3 = format!("{slice:03}");
let coord_ref = format!("{DISPATCH_REF_PREFIX}{slice3}");
let journal_path = format!(".doctrine/dispatch/{slice3}/journal.toml");
let tip = resolve_commit(root, &coord_ref)?
.with_context(|| format!("integrate: dispatch/{slice3} does not exist"))?;
let tip_tree = tree_of(root, &tip)?;
let mut journal = read_ledger::<Journal>(root, &coord_ref, &slice3, "journal.toml")?;
if journal.rows.is_empty() {
bail!("integrate: no prepared journal on dispatch/{slice3} — run prepare-review first");
}
let candidates = read_candidates(root, slice)?;
let candidate_active = !candidates.rows.is_empty();
let fresh = |j: &Journal, target: &str| !j.rows.iter().any(|r| r.target_ref == target);
if let Some(trunk_ref) = trunk.filter(|t| fresh(&journal, t)) {
let row = if candidate_active {
plan_candidate_trunk_row(root, &slice3, &journal, &candidates, trunk_ref)?
} else {
plan_trunk_row(root, &slice3, &journal, &candidates, trunk_ref)?
};
journal.rows.push(row);
}
if let Some(edge_ref) = edge.filter(|e| fresh(&journal, e)) {
let row = if candidate_active {
plan_candidate_edge_row(root, &candidates, edge_ref)?
} else {
plan_edge_row(root, &slice3, edge_ref)?
};
journal.rows.push(row);
}
for row in &journal.rows {
if let Some(wt) = git::worktree_for_ref(root, &row.target_ref)?
&& !git::tree_clean(&wt)?
{
bail!("integrate-dirty-worktree ({})", row.target_ref);
}
}
journal.allowed_clobbers = allow.iter().cloned().collect();
let outcomes = with_journaled_projection(
root,
&tip,
&tip_tree,
&journal_path,
&coord_ref,
&mut journal,
"journal: integrate",
|root, row| advance_row(root, row, allow),
)?;
report_integrate(&journal, &outcomes)
}
fn advance_row(
root: &Path,
row: &mut JournalRow,
allow: &BTreeSet<String>,
) -> anyhow::Result<RowOutcome> {
let actual = resolve_commit(root, &row.target_ref)?;
let current = actual.as_deref().unwrap_or(ZERO_OID);
let planned = row.planned_new_oid.clone();
let expected_old = row.expected_old_oid.clone();
if current == planned {
row.status = LedgerStatus::Verified;
row.applied_new_oid = planned;
return Ok(RowOutcome::Done {
disposition: Disposition::NoOp,
});
}
if current != expected_old {
row.status = LedgerStatus::Failed;
return Ok(RowOutcome::Refused {
token: format!(
"{} (target at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
),
});
}
if current != ZERO_OID
&& let Some(token) = corpus_clobber_refusal(root, &planned, current, allow)?
{
row.status = LedgerStatus::Failed;
return Ok(RowOutcome::Refused { token });
}
match git::worktree_for_ref(root, &row.target_ref)? {
None => advance_pure_ref(root, row, &planned, &expected_old),
Some(wt) => advance_checked_out(root, row, &wt, &planned, &expected_old),
}
}
fn corpus_clobber_refusal(
root: &Path,
new: &str,
cur: &str,
allow: &BTreeSet<String>,
) -> anyhow::Result<Option<String>> {
let base = git::merge_base(root, new, cur)?.unwrap_or_else(|| git::EMPTY_TREE_OID.to_owned());
let changed = git::diff_doctrine_paths(root, &base, cur, corpus_guard::DOCTRINE_PATHSPEC)?;
if changed.is_empty() {
return Ok(None);
}
let readings = changed
.into_iter()
.map(|path| -> anyhow::Result<corpus_guard::ClobberReading> {
let base_oid = git::blob_oid_at(root, &base, &path)?;
let new_oid = git::blob_oid_at(root, new, &path)?;
Ok(corpus_guard::ClobberReading {
path,
base_oid,
new_oid,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
let clobbers = corpus_guard::corpus_clobber_check(&readings, allow);
if clobbers.is_empty() {
Ok(None)
} else {
Ok(Some(format!(
"{} ({})",
corpus_guard::CORPUS_CLOBBER,
corpus_guard::render_clobbers(&clobbers, corpus_guard::CLOBBER_RENDER_CAP),
)))
}
}
fn advance_pure_ref(
root: &Path,
row: &mut JournalRow,
planned: &str,
expected_old: &str,
) -> anyhow::Result<RowOutcome> {
match git::update_ref_cas(root, &row.target_ref, planned, expected_old)? {
RefCas::Moved { actual } => {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!(
"{} (target at {})",
row.target_ref,
actual.as_deref().unwrap_or("?")
),
})
}
RefCas::Updated => {
row.status = LedgerStatus::Verified;
planned.clone_into(&mut row.applied_new_oid);
Ok(RowOutcome::Done {
disposition: Disposition::AdvancedPureRef,
})
}
}
}
fn advance_checked_out(
root: &Path,
row: &mut JournalRow,
wt: &Path,
planned: &str,
expected_old: &str,
) -> anyhow::Result<RowOutcome> {
if git::is_ancestor(root, expected_old, planned)? {
match git::ff_advance_in_worktree(wt, &row.target_ref, planned)? {
git::FfAdvance::Advanced => {
row.status = LedgerStatus::Verified;
planned.clone_into(&mut row.applied_new_oid);
Ok(RowOutcome::Done {
disposition: Disposition::AdvancedResynced,
})
}
git::FfAdvance::Raced { token } => {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!("{} ({token})", row.target_ref),
})
}
}
} else {
row.status = LedgerStatus::Failed;
Ok(RowOutcome::Refused {
token: format!("integrate-nonff-checkout ({})", row.target_ref),
})
}
}
fn report_integrate(journal: &Journal, outcomes: &[RowOutcome]) -> anyhow::Result<()> {
let mut applied_refs: Vec<String> = Vec::new();
let mut detail: Vec<String> = Vec::new();
let mut refusals: Vec<String> = Vec::new();
for (row, outcome) in journal.rows.iter().zip(outcomes) {
match outcome {
RowOutcome::Done { disposition } => match disposition {
Disposition::NoOp => {
detail.push(format!("integrate: {} (no-op)", row.target_ref));
}
disp => {
applied_refs.push(row.target_ref.clone());
detail.push(format!(
"integrate: {} {}..{} ({})",
row.target_ref,
short_oid(&row.expected_old_oid),
short_oid(&row.applied_new_oid),
disp.label(),
));
}
},
RowOutcome::Refused { token } => refusals.push(token.clone()),
}
}
for refname in &applied_refs {
writeln!(io::stdout(), "{refname}")?;
}
for line in &detail {
writeln!(io::stderr(), "{line}")?;
}
if refusals.is_empty() {
writeln!(
io::stderr(),
"integrate: {} ref(s) replayed",
journal.rows.len()
)?;
Ok(())
} else {
bail!(
"integrate: {} moved target(s), not clobbered: {}",
refusals.len(),
refusals.join(", ")
)
}
}
fn phase_chain_tip(journal: &Journal, slice3: &str) -> Option<String> {
let prefix = format!("{PHASE_REF_PREFIX}{slice3}-");
journal
.rows
.iter()
.filter(|r| r.status == LedgerStatus::Verified)
.filter_map(|r| {
r.target_ref
.strip_prefix(&prefix)
.and_then(|nn| nn.parse::<u32>().ok())
.map(|n| (n, r.target_ref.clone()))
})
.max_by_key(|(n, _)| *n)
.map(|(_, refname)| refname)
}
const NO_CLOSE_TARGET_ADMISSION: &str = "a candidate workflow is active but no close_target admission exists — run \
`dispatch candidate admit --role close_target` first; integration will not fall back \
to a raw phase ref";
fn resolve_trunk_payload(
root: &Path,
slice3: &str,
journal: &Journal,
candidates: &Candidates,
) -> anyhow::Result<String> {
if candidates.rows.is_empty() {
let phase_ref = phase_chain_tip(journal, slice3)
.with_context(|| format!("no phase/{slice3}-NN code units to integrate"))?;
resolve_commit(root, &phase_ref)?.with_context(|| format!("{phase_ref} does not resolve"))
} else {
candidates
.current_admission
.close_target
.as_ref()
.context(NO_CLOSE_TARGET_ADMISSION)
.map(|a| a.admitted_oid.clone())
}
}
fn recorded_row(trunk_ref: &str, payload: String) -> JournalRow {
JournalRow {
source_oid: payload.clone(),
target_ref: trunk_ref.to_owned(),
expected_old_oid: payload.clone(), planned_new_oid: payload.clone(), applied_new_oid: payload, status: LedgerStatus::Verified, }
}
fn plan_recorded_trunk_row(
root: &Path,
slice3: &str,
journal: &Journal,
candidates: &Candidates,
trunk_ref: &str,
) -> anyhow::Result<JournalRow> {
let payload = resolve_trunk_payload(root, slice3, journal, candidates)?;
let tip = resolve_commit(root, trunk_ref)?.with_context(|| {
format!("record-integration: {trunk_ref} does not resolve — no trunk to record onto")
})?;
anyhow::ensure!(
git::is_ancestor(root, &payload, &tip)?,
"record-integration: trunk payload {payload} is not an ancestor of {trunk_ref} \
(at {tip}) — land it (`git merge --no-ff phase/{slice3}-NN` or the admitted \
candidate) before recording"
);
Ok(recorded_row(trunk_ref, payload))
}
fn plan_trunk_row(
root: &Path,
slice3: &str,
journal: &Journal,
candidates: &Candidates,
trunk_ref: &str,
) -> anyhow::Result<JournalRow> {
let planned = resolve_trunk_payload(root, slice3, journal, candidates)?;
let expected_old = resolve_commit(root, trunk_ref)?;
if let Some(tip) = &expected_old {
anyhow::ensure!(
git::is_ancestor(root, tip, &planned)?,
"integrate --trunk: {planned} does not fast-forward {trunk_ref} (at {tip}) — \
trunk moved; re-anchor required, not auto-resolved"
);
}
Ok(projection_row(trunk_ref, planned, expected_old))
}
fn plan_edge_row(root: &Path, slice3: &str, edge_ref: &str) -> anyhow::Result<JournalRow> {
let review_ref = format!("{REVIEW_REF_PREFIX}{slice3}");
let planned = resolve_commit(root, &review_ref)?
.with_context(|| format!("integrate --edge: {review_ref} does not resolve"))?;
let expected_old = resolve_commit(root, edge_ref)?;
Ok(projection_row(edge_ref, planned, expected_old))
}
fn plan_candidate_trunk_row(
root: &Path,
slice3: &str,
journal: &Journal,
candidates: &Candidates,
trunk_ref: &str,
) -> anyhow::Result<JournalRow> {
let planned = resolve_trunk_payload(root, slice3, journal, candidates)?;
let expected_old = resolve_commit(root, trunk_ref)?;
if let Some(tip) = &expected_old {
anyhow::ensure!(
git::is_ancestor(root, tip, &planned)?,
"integrate --trunk: admitted close_target {planned} does not fast-forward {trunk_ref} \
(at {tip}) — trunk moved; create a superseding close-target candidate on the new \
base and re-admit (not auto-resolved)"
);
}
Ok(projection_row(trunk_ref, planned, expected_old))
}
fn plan_candidate_edge_row(
root: &Path,
candidates: &Candidates,
edge_ref: &str,
) -> anyhow::Result<JournalRow> {
let admission = candidates
.current_admission
.review_surface
.as_ref()
.context(
"integrate --edge: a candidate workflow is active but no review_surface admission \
exists — run `dispatch candidate admit --role review_surface` first; integrate will \
not fall back to the raw review ref",
)?;
let planned = admission.admitted_oid.clone();
let expected_old = resolve_commit(root, edge_ref)?;
Ok(projection_row(edge_ref, planned, expected_old))
}
fn projection_row(target_ref: &str, planned: String, expected_old: Option<String>) -> JournalRow {
JournalRow {
source_oid: planned.clone(),
target_ref: target_ref.to_owned(),
expected_old_oid: expected_old.unwrap_or_else(|| ZERO_OID.to_owned()),
planned_new_oid: planned,
applied_new_oid: String::new(),
status: LedgerStatus::Pending,
}
}
fn read_ledger<T: serde::de::DeserializeOwned + Default>(
root: &Path,
coord_ref: &str,
slice3: &str,
file: &str,
) -> anyhow::Result<T> {
let path = format!(".doctrine/dispatch/{slice3}/{file}");
match git::read_path_at(root, coord_ref, &path)? {
Some(text) => Ok(toml::from_str(&text)?),
None => Ok(T::default()),
}
}
fn plan_review(
root: &Path,
slice3: &str,
tip: &str,
tip_tree: &str,
trunk_base: &str,
orthogonal: &Orthogonal,
planned: &mut Vec<Planned>,
) -> anyhow::Result<()> {
let mut exclude: Vec<String> = vec![format!(".doctrine/dispatch/{slice3}")];
for mark in &orthogonal.rows {
if mark.status == LedgerStatus::Verified {
exclude.push(mark.path.clone());
}
}
let exclude_refs: Vec<&str> = exclude.iter().map(String::as_str).collect();
let review_tree = git::filter_tree(root, tip_tree, &exclude_refs)?;
let review_commit = git::commit_tree(
root,
&review_tree,
trunk_base,
&format!("review({slice3}): impl bundle"),
)?;
planned.push(Planned {
target_ref: format!("{REVIEW_REF_PREFIX}{slice3}"),
source_oid: tip.to_owned(),
commit_oid: review_commit,
});
Ok(())
}
fn plan_phases(
root: &Path,
slice3: &str,
trunk_base: &str,
boundaries: &Boundaries,
planned: &mut Vec<Planned>,
) -> anyhow::Result<()> {
let ordinal = |row: &BoundaryRow| -> Option<u32> {
row.phase.strip_prefix("PHASE-")?.parse::<u32>().ok()
};
let mut ordered: Vec<&BoundaryRow> = boundaries.rows.iter().collect();
ordered.sort_by_key(|row| {
let ord = ordinal(row);
(ord.is_none(), ord.unwrap_or(0))
});
let mut parent = trunk_base.to_owned();
for boundary in ordered {
if boundary.code_start_oid == boundary.code_end_oid {
continue; }
let nn = boundary
.phase
.strip_prefix("PHASE-")
.unwrap_or(&boundary.phase);
let code_tree = tree_of(root, &boundary.code_end_oid)?;
let phase_tree =
git::filter_tree(root, &code_tree, &[crate::corpus_guard::DOCTRINE_PATHSPEC])?;
let phase_commit =
git::commit_tree(root, &phase_tree, &parent, &format!("phase({slice3}-{nn})"))?;
planned.push(Planned {
target_ref: format!("{PHASE_REF_PREFIX}{slice3}-{nn}"),
source_oid: boundary.code_end_oid.clone(),
commit_oid: phase_commit.clone(),
});
parent = phase_commit;
}
Ok(())
}
fn pending_journal(planned: &[Planned]) -> Journal {
Journal {
rows: planned
.iter()
.map(|p| JournalRow {
source_oid: p.source_oid.clone(),
target_ref: p.target_ref.clone(),
expected_old_oid: ZERO_OID.to_owned(),
planned_new_oid: p.commit_oid.clone(),
applied_new_oid: String::new(),
status: LedgerStatus::Pending,
})
.collect(),
allowed_clobbers: Vec::new(),
}
}
fn commit_journal(
root: &Path,
base_tree: &str,
parent: &str,
journal_path: &str,
coord_ref: &str,
journal: &Journal,
msg: &str,
) -> anyhow::Result<String> {
let body = journal.to_toml()?;
let tree = git::tree_with_file(root, base_tree, journal_path, &body)?;
let commit = git::commit_tree(root, &tree, parent, msg)?;
match git::update_ref_cas(root, coord_ref, &commit, parent)? {
RefCas::Updated => Ok(commit),
RefCas::Moved { actual } => bail!(
"journal-commit: dispatch branch moved under us (expected {parent}, found {})",
actual.as_deref().unwrap_or("?")
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Disposition {
Created,
NoOp,
AdvancedResynced,
AdvancedPureRef,
}
impl Disposition {
fn label(self) -> &'static str {
match self {
Self::Created => "created",
Self::NoOp => "no-op",
Self::AdvancedResynced => "advanced+resynced",
Self::AdvancedPureRef => "advanced+pure-ref",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RowOutcome {
Done { disposition: Disposition },
Refused { token: String },
}
#[expect(
clippy::too_many_arguments,
reason = "thin journal-cycle bracket threads the commit_journal arg set plus the apply closure"
)]
fn with_journaled_projection(
root: &Path,
tip: &str,
tip_tree: &str,
journal_path: &str,
coord_ref: &str,
journal: &mut Journal,
message: &str,
mut apply: impl FnMut(&Path, &mut JournalRow) -> anyhow::Result<RowOutcome>,
) -> anyhow::Result<Vec<RowOutcome>> {
let journal_commit = commit_journal(
root,
tip_tree,
tip,
journal_path,
coord_ref,
journal,
message,
)?;
let mut outcomes = Vec::with_capacity(journal.rows.len());
for row in &mut journal.rows {
outcomes.push(apply(root, row)?);
}
commit_journal(
root,
tip_tree,
&journal_commit,
journal_path,
coord_ref,
journal,
message,
)?;
Ok(outcomes)
}
pub(crate) fn render_phase_table(rows: &[(String, String, String)]) -> String {
use comfy_table::Table;
let mut table = Table::new();
table
.load_preset(comfy_table::presets::NOTHING)
.set_header(vec![" ID", " Status", " Name"])
.force_no_tty();
for (id, status, name) in rows {
table.add_row(vec![
format!(" {id}"),
format!(" {status}"),
format!(" {name}"),
]);
}
let out = table.to_string();
out.lines()
.map(|l| l.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn run_plan_next(path: Option<PathBuf>, slice: u32, json: bool) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let rows = plan_next_rows(&root, slice)?;
let next = compute_next_phases(&rows);
let saw_blocked = rows.iter().any(|(_, status, _)| status == "blocked");
if json {
#[derive(serde::Serialize)]
struct PhaseRow {
id: String,
name: String,
status: String,
}
#[derive(serde::Serialize)]
struct Output {
phases: Vec<PhaseRow>,
next: Vec<String>,
batching_requires_phase_plan: bool,
}
let output = Output {
phases: rows
.iter()
.map(|(id, status, name)| PhaseRow {
id: id.clone(),
name: name.clone(),
status: status.clone(),
})
.collect(),
next,
batching_requires_phase_plan: true,
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&output)?)?;
} else {
let table = render_phase_table(&rows);
writeln!(io::stdout(), "{table}")?;
if next.is_empty() {
if saw_blocked {
writeln!(
io::stdout(),
"\nnext: (none — all remaining phases are blocked)"
)?;
}
} else {
let ids = next.join(", ");
writeln!(io::stdout(), "\nnext: {ids}")?;
writeln!(
io::stdout(),
" ⚠ run /phase-plan before parallel spawn; do not assume file-disjointness"
)?;
}
}
Ok(())
}
struct Drift {
trunk_tip: String,
fork_point: String,
ahead: u32,
}
fn trunk_drift(root: &Path, tip: &str) -> anyhow::Result<Option<Drift>> {
let trunk_tip = git::trunk_commit(root)?.with_context(|| "trunk ref not found")?;
let Some(fork_point) = git::merge_base(root, tip, &trunk_tip)? else {
return Ok(None);
};
let ahead_cnt = git::git_text(
root,
&["rev-list", "--count", &format!("{fork_point}..{trunk_tip}")],
)?;
let ahead: u32 = ahead_cnt.trim().parse().unwrap_or(0);
Ok(Some(Drift {
trunk_tip,
fork_point,
ahead,
}))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReceiptStatus {
NotStarted,
InProgress,
Blocked,
Completed,
ConcludeIncomplete,
Unknown,
}
impl ReceiptStatus {
pub(crate) fn as_str(self) -> &'static str {
match self {
ReceiptStatus::NotStarted => "not-started",
ReceiptStatus::InProgress => "in-progress",
ReceiptStatus::Blocked => "blocked",
ReceiptStatus::Completed => "completed",
ReceiptStatus::ConcludeIncomplete => "conclude-incomplete",
ReceiptStatus::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PhaseProjection {
pub id: String,
pub name: String,
pub status: ReceiptStatus,
pub legacy_status: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SheetClass {
Unreadable,
Started,
InProgress,
Blocked,
Completed,
}
fn classify_sheet(sheet: &anyhow::Result<Option<String>>) -> SheetClass {
use crate::state::PhaseStatus;
let Ok(read) = sheet else {
return SheetClass::Unreadable;
};
let Some(s) = read.as_deref() else {
return SheetClass::Started;
};
if s == PhaseStatus::InProgress.as_str() {
SheetClass::InProgress
} else if s == PhaseStatus::Blocked.as_str() {
SheetClass::Blocked
} else if s == PhaseStatus::Completed.as_str() {
SheetClass::Completed
} else if s == PhaseStatus::Planned.as_str() {
SheetClass::Started
} else {
SheetClass::Unreadable
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Band {
Concluded,
Running,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SheetPat {
Any,
Is(SheetClass),
}
impl SheetPat {
fn admits(self, got: SheetClass) -> bool {
match self {
SheetPat::Any => true,
SheetPat::Is(want) => want == got,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BoundaryPat {
Present,
Absent,
}
const RECEIPT_MATRIX: &[(Band, SheetPat, BoundaryPat, ReceiptStatus)] = &[
(
Band::Concluded,
SheetPat::Any,
BoundaryPat::Present,
ReceiptStatus::Completed,
),
(
Band::Concluded,
SheetPat::Any,
BoundaryPat::Absent,
ReceiptStatus::ConcludeIncomplete,
),
(
Band::Running,
SheetPat::Any,
BoundaryPat::Present,
ReceiptStatus::ConcludeIncomplete,
),
(
Band::Running,
SheetPat::Is(SheetClass::Unreadable),
BoundaryPat::Absent,
ReceiptStatus::Unknown,
),
(
Band::Running,
SheetPat::Is(SheetClass::Completed),
BoundaryPat::Absent,
ReceiptStatus::ConcludeIncomplete,
),
(
Band::Running,
SheetPat::Is(SheetClass::InProgress),
BoundaryPat::Absent,
ReceiptStatus::InProgress,
),
(
Band::Running,
SheetPat::Is(SheetClass::Blocked),
BoundaryPat::Absent,
ReceiptStatus::Blocked,
),
(
Band::Running,
SheetPat::Is(SheetClass::Started),
BoundaryPat::Absent,
ReceiptStatus::InProgress,
),
];
fn derive_receipt_status(
sheet: anyhow::Result<Option<String>>,
has_boundary: bool,
position: Option<Position>,
) -> ReceiptStatus {
let Some(position) = position else {
return derive_receipt_status_pre_funnel(sheet, has_boundary);
};
let band = if position >= Position::Concluded {
Band::Concluded
} else {
Band::Running
};
let sheet = classify_sheet(&sheet);
let boundary = if has_boundary {
BoundaryPat::Present
} else {
BoundaryPat::Absent
};
RECEIPT_MATRIX
.iter()
.find(|(b, s, bd, _)| *b == band && *bd == boundary && s.admits(sheet))
.map_or(ReceiptStatus::Unknown, |(_, _, _, status)| *status)
}
fn derive_receipt_status_pre_funnel(
sheet: anyhow::Result<Option<String>>,
has_boundary: bool,
) -> ReceiptStatus {
use crate::state::PhaseStatus;
match sheet {
Err(_) => ReceiptStatus::Unknown,
Ok(None) => ReceiptStatus::NotStarted,
Ok(Some(s)) => {
if s == PhaseStatus::InProgress.as_str() {
ReceiptStatus::InProgress
} else if s == PhaseStatus::Blocked.as_str() {
ReceiptStatus::Blocked
} else if s == PhaseStatus::Completed.as_str() {
if has_boundary {
ReceiptStatus::Completed
} else {
ReceiptStatus::ConcludeIncomplete
}
} else if s == PhaseStatus::Planned.as_str() {
ReceiptStatus::NotStarted
} else {
ReceiptStatus::Unknown
}
}
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "PHASE-03 consumers are the first non-test callers; run_status renders the verbatim legacy string for behaviour-preservation"
)
)]
fn receipt_status_legacy_str(status: ReceiptStatus) -> &'static str {
match status {
ReceiptStatus::Completed | ReceiptStatus::ConcludeIncomplete => "completed",
ReceiptStatus::InProgress => "in_progress",
ReceiptStatus::Blocked => "blocked",
ReceiptStatus::NotStarted => "pending",
ReceiptStatus::Unknown => "unknown",
}
}
fn phase_projection(
plan: &crate::plan::Plan,
state_dir: &Path,
committed: &BTreeSet<&str>,
) -> Vec<PhaseProjection> {
plan.phases
.iter()
.map(|ph| {
let stem = ph.id.to_lowercase();
let sheet = crate::state::read_phase_status(state_dir, &stem);
let has_boundary = committed.contains(ph.id.as_str());
let legacy_status = match &sheet {
Ok(Some(s)) => s.clone(),
Ok(None) => "pending".to_string(),
Err(_) => "unknown".to_string(),
};
PhaseProjection {
id: ph.id.clone(),
name: ph.name.clone(),
status: derive_receipt_status(sheet, has_boundary, None),
legacy_status,
}
})
.collect()
}
pub(crate) fn phase_receipt_status(
state_dir: &Path,
phase: &str,
has_boundary: bool,
position: Option<Position>,
) -> ReceiptStatus {
let stem = phase.to_lowercase();
let sheet = crate::state::read_phase_status(state_dir, &stem);
derive_receipt_status(sheet, has_boundary, position)
}
pub(crate) fn run_status(path: Option<PathBuf>, slice: u32, json: bool) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice3 = format!("{slice:03}");
let dispatch_ref = format!("{DISPATCH_REF_PREFIX}{slice3}");
let dispatch_tip = resolve_commit(&root, &dispatch_ref)?.with_context(|| {
format!("dispatch branch not found; run 'dispatch setup --slice {slice}' first")
})?;
let dispatch_short = git::git_text(&root, &["rev-parse", "--short=7", &dispatch_tip])?;
let coord_state = find_coordination_worktree(&root, &slice3);
let Drift {
trunk_tip,
fork_point,
ahead,
} = trunk_drift(&root, &dispatch_tip)?
.with_context(|| format!("dispatch/{slice3} and trunk share no common ancestor"))?;
let trunk_state = if ahead == 0 { "stable" } else { "moved" };
let plan = crate::slice::read_plan(&root.join(".doctrine/slice"), slice)?;
let state_dir = crate::state::phases_dir(&root, slice);
let boundaries = read_ledger::<Boundaries>(&root, &dispatch_ref, &slice3, "boundaries.toml")?;
let committed: BTreeSet<&str> = boundaries.rows.iter().map(|r| r.phase.as_str()).collect();
let phase_rows: Vec<(String, String, String)> = phase_projection(&plan, &state_dir, &committed)
.into_iter()
.map(|p| (p.id, p.legacy_status, p.name))
.collect();
let review_ref = format!("{REVIEW_REF_PREFIX}{slice3}");
let review_exists = resolve_commit(&root, &review_ref)?.is_some();
let phase_ref_count = count_phase_refs(&root, &slice3);
let candidates = read_candidates(&root, slice)?;
let candidate_total = candidates.rows.len();
let candidate_admitted = [
candidates.current_admission.close_target.is_some(),
candidates.current_admission.review_surface.is_some(),
]
.into_iter()
.filter(|&x| x)
.count();
let all_completed = phase_rows
.iter()
.all(|(_, status, _)| status == "completed");
let coord_live = !matches!(coord_state.as_str(), "(removed)");
let admitted_ct = candidates.current_admission.close_target.as_ref();
let review_tip = if review_exists {
resolve_commit(&root, &review_ref)?.unwrap_or(dispatch_tip)
} else {
dispatch_tip
};
let bundle_stale = all_completed && trunk_drift(&root, &review_tip)?.map_or(0, |d| d.ahead) > 0;
let admitted_is_ancestor = match admitted_ct {
Some(ct) if !coord_live => is_ancestor_of_trunk(&root, &ct.admitted_oid, &trunk_tip)?,
_ => false,
};
let next_guidance = select_guidance(GuidanceInputs {
all_completed,
bundle_stale,
review_exists,
coord_live,
admitted: admitted_ct.is_some(),
admitted_is_ancestor,
next_phases: || compute_next_phases(&phase_rows),
});
if json {
let output = StatusOutput {
dispatch: DispatchState {
r#ref: dispatch_ref,
tip: dispatch_short,
},
coord: CoordState {
state: if coord_live {
"live".to_string()
} else {
"removed".to_string()
},
path: if coord_live { Some(coord_state) } else { None },
},
trunk: TrunkState {
state: trunk_state.to_string(),
fork_point,
ahead,
},
phases: phase_rows
.iter()
.map(|(id, status, name)| PhaseState {
id: id.clone(),
name: name.clone(),
status: status.clone(),
})
.collect(),
sync: SyncState {
state: if review_exists {
"prepared".to_string()
} else {
"not_prepared".to_string()
},
review_ref: if review_exists {
Some(review_ref)
} else {
None
},
phase_cuts: phase_ref_count,
},
candidates: CandidateSummary {
total: candidate_total,
admitted: candidate_admitted,
},
next: next_guidance.to_json(),
};
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&output)?)?;
} else {
writeln!(io::stdout(), "dispatch: {dispatch_ref} ({dispatch_short})")?;
writeln!(io::stdout(), "coord: {coord_state}")?;
if ahead > 0 {
writeln!(
io::stdout(),
"trunk: {trunk_state} ({ahead} commit(s) ahead of fork-point)"
)?;
} else {
writeln!(io::stdout(), "trunk: {trunk_state}")?;
}
writeln!(io::stdout())?;
writeln!(io::stdout(), "phases:")?;
write!(io::stdout(), "{}", render_phase_table(&phase_rows))?;
writeln!(io::stdout())?;
writeln!(io::stdout())?;
if review_exists {
writeln!(
io::stdout(),
"sync: prepared — {review_ref} ({phase_ref_count} phase cut(s))"
)?;
} else {
writeln!(io::stdout(), "sync: not yet run")?;
}
writeln!(
io::stdout(),
"candidates: {candidate_total} ({candidate_admitted} admitted)"
)?;
match &next_guidance {
NextGuidance::Phases { phases } => {
let ids = phases.join(", ");
writeln!(io::stdout(), "next: {ids}")?;
}
NextGuidance::RefreshBase => {
writeln!(
io::stdout(),
"next: trunk advanced past the prepared base — run 'dispatch refresh-base --slice {slice}' then re-prepare"
)?;
}
NextGuidance::PrepareReview => {
writeln!(
io::stdout(),
"next: all phases completed — run 'dispatch sync --prepare-review'"
)?;
}
NextGuidance::AuditThenIntegrate => {
writeln!(
io::stdout(),
"next: all phases completed — admitted candidate exists; run audit then 'dispatch sync --integrate'"
)?;
}
NextGuidance::AuditOrCandidateStatus => {
writeln!(
io::stdout(),
"next: all phases completed — review ref prepared; run audit or 'dispatch candidate status'"
)?;
}
NextGuidance::Complete => {
writeln!(
io::stdout(),
"next: complete — coordination worktree removed; slice is integrated"
)?;
}
NextGuidance::AwaitingIntegration => {
writeln!(
io::stdout(),
"next: awaiting integration — run 'dispatch sync --integrate' after audit"
)?;
}
}
}
Ok(())
}
fn find_coordination_worktree(root: &Path, slice3: &str) -> String {
let target_branch = format!("{DISPATCH_REF_PREFIX}{slice3}");
match git::worktree_for_ref(root, &target_branch) {
Ok(Some(path)) => path.to_string_lossy().into_owned(),
Ok(None) | Err(_) => "(removed)".to_string(),
}
}
fn count_phase_refs(root: &Path, slice3: &str) -> usize {
let pattern = format!("{PHASE_REF_PREFIX}{slice3}-*");
let Ok(out) = git::git_text(root, &["for-each-ref", "--format=%(refname)", &pattern]) else {
return 0;
};
if out.trim().is_empty() {
0
} else {
out.lines().count()
}
}
pub(crate) fn plan_next_rows(
root: &Path,
slice: u32,
) -> anyhow::Result<Vec<(String, String, String)>> {
let plan = crate::slice::read_plan(&root.join(".doctrine/slice"), slice)?;
let state_dir = crate::state::phases_dir(root, slice);
let committed: BTreeSet<&str> = BTreeSet::new();
Ok(phase_projection(&plan, &state_dir, &committed)
.into_iter()
.map(|p| (p.id, p.legacy_status, p.name))
.collect())
}
pub(crate) fn compute_next_phases(rows: &[(String, String, String)]) -> Vec<String> {
let mut next: Vec<String> = Vec::new();
let mut found_actionable = false;
for (id, status, _) in rows {
match status.as_str() {
"completed" => {}
"blocked" => {
if found_actionable {
break;
}
}
"in_progress" => {
if !found_actionable {
next.push(id.clone());
break;
}
}
_ => {
if !found_actionable {
next.push(id.clone());
found_actionable = true;
} else if status.as_str() == "pending" {
next.push(id.clone());
} else {
break;
}
}
}
}
next
}
fn is_ancestor_of_trunk(root: &Path, oid: &str, trunk_tip: &str) -> anyhow::Result<bool> {
if oid == trunk_tip {
return Ok(true);
}
let mb = git::merge_base(root, oid, trunk_tip)?;
Ok(mb.as_deref() == Some(oid))
}
#[derive(serde::Serialize)]
struct StatusOutput {
dispatch: DispatchState,
coord: CoordState,
trunk: TrunkState,
phases: Vec<PhaseState>,
sync: SyncState,
candidates: CandidateSummary,
next: NextJson,
}
#[derive(serde::Serialize)]
struct DispatchState {
#[serde(rename = "ref")]
r#ref: String,
tip: String,
}
#[derive(serde::Serialize)]
struct CoordState {
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
}
#[derive(serde::Serialize)]
struct TrunkState {
state: String,
fork_point: String,
ahead: u32,
}
#[derive(serde::Serialize)]
struct PhaseState {
id: String,
name: String,
status: String,
}
#[derive(serde::Serialize)]
struct SyncState {
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
review_ref: Option<String>,
phase_cuts: usize,
}
#[derive(serde::Serialize)]
struct CandidateSummary {
total: usize,
admitted: usize,
}
#[derive(serde::Serialize)]
struct NextJson {
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
phases: Option<Vec<String>>,
}
struct GuidanceInputs<F: FnOnce() -> Vec<String>> {
all_completed: bool,
bundle_stale: bool,
review_exists: bool,
coord_live: bool,
admitted: bool,
admitted_is_ancestor: bool,
next_phases: F,
}
fn select_guidance<F: FnOnce() -> Vec<String>>(inputs: GuidanceInputs<F>) -> NextGuidance {
let GuidanceInputs {
all_completed,
bundle_stale,
review_exists,
coord_live,
admitted,
admitted_is_ancestor,
next_phases,
} = inputs;
if !all_completed {
NextGuidance::Phases {
phases: next_phases(),
}
} else if bundle_stale {
NextGuidance::RefreshBase
} else if !review_exists {
NextGuidance::PrepareReview
} else if coord_live && admitted {
NextGuidance::AuditThenIntegrate
} else if coord_live {
NextGuidance::AuditOrCandidateStatus
} else if admitted {
if admitted_is_ancestor {
NextGuidance::Complete
} else {
NextGuidance::AwaitingIntegration
}
} else {
NextGuidance::AuditOrCandidateStatus
}
}
enum NextGuidance {
Phases {
phases: Vec<String>,
},
RefreshBase,
PrepareReview,
AuditThenIntegrate,
AuditOrCandidateStatus,
Complete,
AwaitingIntegration,
}
impl NextGuidance {
fn to_json(&self) -> NextJson {
match self {
NextGuidance::Phases { phases } => NextJson {
kind: "phases".to_string(),
phases: Some(phases.clone()),
},
NextGuidance::RefreshBase => NextJson {
kind: "refresh_base".to_string(),
phases: None,
},
NextGuidance::PrepareReview => NextJson {
kind: "blocked".to_string(),
phases: None,
},
NextGuidance::AuditThenIntegrate | NextGuidance::AuditOrCandidateStatus => NextJson {
kind: "audit".to_string(),
phases: None,
},
NextGuidance::Complete => NextJson {
kind: "completed".to_string(),
phases: None,
},
NextGuidance::AwaitingIntegration => NextJson {
kind: "awaiting_integration".to_string(),
phases: None,
},
}
}
}
const FUNNEL_MARKER: &str = "dispatch-funnel";
pub(crate) fn funnel_message(slice: u32, phase: &str) -> String {
format!("{FUNNEL_MARKER}: SL-{slice:03} {phase}")
}
pub(crate) fn dispatch_ref(slice: u32) -> String {
format!("{DISPATCH_REF_PREFIX}{slice:03}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Identity {
pub(crate) name: String,
pub(crate) email: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Provenance {
Import {
author: Identity,
committer: Identity,
},
Conclude { who: Identity },
}
impl Provenance {
fn author(&self) -> &Identity {
match self {
Provenance::Import { author, .. } => author,
Provenance::Conclude { who } => who,
}
}
fn committer(&self) -> &Identity {
match self {
Provenance::Import { committer, .. } => committer,
Provenance::Conclude { who } => who,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CommitRefusal {
EmptyDelta,
LostRefRace,
}
impl CommitRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
CommitRefusal::EmptyDelta => "empty-delta",
CommitRefusal::LostRefRace => "lost-ref-race",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CommitOutcome {
Landed { oid: String },
Refused(CommitRefusal),
}
const DISPATCH_NAME: &str = "dispatch";
const DISPATCH_EMAIL: &str = "dispatch@doctrine";
pub(crate) fn dispatch_identity() -> Identity {
Identity {
name: DISPATCH_NAME.to_owned(),
email: DISPATCH_EMAIL.to_owned(),
}
}
fn commit_tree_as(
root: &Path,
tree: &str,
parent: &str,
message: &str,
prov: &Provenance,
) -> anyhow::Result<String> {
let author = prov.author();
let committer = prov.committer();
let output = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["commit-tree", tree, "-p", parent, "-m", message])
.env("GIT_AUTHOR_NAME", &author.name)
.env("GIT_AUTHOR_EMAIL", &author.email)
.env("GIT_COMMITTER_NAME", &committer.name)
.env("GIT_COMMITTER_EMAIL", &committer.email)
.output()
.with_context(|| format!("spawn git commit-tree in {}", root.display()))?;
if !output.status.success() {
bail!(
"commit-tree {tree} -p {parent}: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub(crate) fn commit_on_behalf(
git_root: &Path,
target_ref: &str,
expected_old: &str,
tree: &str,
message: &str,
prov: &Provenance,
) -> anyhow::Result<CommitOutcome> {
let parent_tree = git::git_text(
git_root,
&["rev-parse", &format!("{expected_old}^{{tree}}")],
)
.context("resolve parent tree")?;
let new_tree = git::git_text(git_root, &["rev-parse", &format!("{tree}^{{tree}}")])
.context("resolve composed tree")?;
if new_tree == parent_tree {
return Ok(CommitOutcome::Refused(CommitRefusal::EmptyDelta));
}
let oid = commit_tree_as(git_root, tree, expected_old, message, prov)?;
match git::update_ref_cas(git_root, target_ref, &oid, expected_old)? {
git::RefCas::Updated => Ok(CommitOutcome::Landed { oid }),
git::RefCas::Moved { .. } => Ok(CommitOutcome::Refused(CommitRefusal::LostRefRace)),
}
}
pub(crate) fn land_boundary_row(
git_root: &Path,
coord_ref: &str,
tip: &str,
slice: u32,
row: BoundaryRow,
prov: &Provenance,
) -> anyhow::Result<CommitOutcome> {
let phase = row.phase.clone(); let tree = compose_boundary_tree(git_root, tip, slice, row)?;
commit_on_behalf(
git_root,
coord_ref,
tip,
&tree,
&funnel_message(slice, &phase),
prov,
)
}
pub(crate) fn compose_boundary_tree(
git_root: &Path,
tip: &str,
slice: u32,
row: BoundaryRow,
) -> anyhow::Result<String> {
let path = format!(".doctrine/dispatch/{slice:03}/boundaries.toml");
let mut b =
read_ledger::<Boundaries>(git_root, tip, &format!("{slice:03}"), "boundaries.toml")?;
match b.rows.iter_mut().find(|r| r.phase == row.phase) {
Some(existing) => *existing = row, None => b.rows.push(row),
}
Ok(git::tree_with_file(
git_root,
&tree_of(git_root, tip)?,
&path,
&b.to_toml()?,
)?)
}
pub(crate) mod funnel {
use std::path::Path;
use anyhow::{Context as _, bail};
use serde::{Deserialize, Serialize};
use crate::funnel_machine::{self, IllegalTransition, PhaseRow, Position, Transition};
use crate::git;
use super::{
CommitOutcome, CommitRefusal, Provenance, commit_on_behalf, funnel_message, tree_of,
};
const SCHEMA: u32 = 1;
fn current_schema() -> u32 {
SCHEMA
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct FunnelRecord {
#[serde(default = "current_schema")]
pub schema: u32,
#[serde(default, rename = "phase")]
pub rows: Vec<PhaseRow>,
}
impl Default for FunnelRecord {
fn default() -> Self {
Self {
schema: SCHEMA,
rows: Vec::new(),
}
}
}
impl FunnelRecord {
pub(crate) fn parse(text: &str) -> anyhow::Result<FunnelRecord> {
Ok(toml::from_str(text)?)
}
pub(crate) fn render(&self) -> anyhow::Result<String> {
Ok(toml::to_string(self)?)
}
pub(crate) fn row(&self, phase: &str) -> Option<&PhaseRow> {
self.rows.iter().find(|r| r.id == phase)
}
pub(crate) fn position(&self, phase: &str) -> Option<Position> {
self.row(phase).map(|r| r.position)
}
pub(crate) fn rows_for_fork<'a>(
&'a self,
fork: &'a str,
) -> impl Iterator<Item = &'a PhaseRow> {
self.rows
.iter()
.filter(move |r| r.spawn.as_ref().is_some_and(|s| s.fork == fork))
}
fn upsert(&mut self, row: PhaseRow) {
match self.rows.iter_mut().find(|r| r.id == row.id) {
Some(existing) => *existing = row,
None => self.rows.push(row),
}
}
}
pub(crate) fn read_funnel_at(
root: &Path,
tip: &str,
slice: u32,
) -> anyhow::Result<FunnelRecord> {
let path = funnel_machine::funnel_record_path(slice);
match git::read_path_at(root, tip, &path)? {
Some(text) => {
FunnelRecord::parse(&text).with_context(|| format!("parse {path} at {tip}"))
}
None => Ok(FunnelRecord::default()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LandingVerdict {
Landed,
NotProven,
Ambiguous,
NoRow,
}
pub(crate) fn resolve_landing(
root: &Path,
tip: &str,
slice: u32,
fork: &str,
) -> anyhow::Result<LandingVerdict> {
let record = read_funnel_at(root, tip, slice)?;
let mut rows = record.rows_for_fork(fork);
let Some(row) = rows.next() else {
return Ok(LandingVerdict::NoRow);
};
if rows.next().is_some() {
return Ok(LandingVerdict::Ambiguous);
}
if row.position < Position::Concluded {
return Ok(LandingVerdict::NotProven);
}
let Some(import) = row.import.as_ref() else {
bail!(
"funnel-record integrity: SL-{slice:03} {} stands at {} with no [phase.import] — \
a position past `imported` cannot exist without the import it records",
row.id,
row.position.as_str()
);
};
let live = git::git_opt(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{fork}^{{commit}}"),
],
)?;
Ok(match live {
Some(live) if live == import.fork_tip => LandingVerdict::Landed,
_ => LandingVerdict::NotProven,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FunnelLanding {
Landed { oid: String, position: Position },
Replayed { position: Position },
Illegal(IllegalTransition),
CommitRefused(CommitRefusal),
}
#[expect(
clippy::too_many_arguments,
reason = "one call = the full transition context (target ref, tip, phase, facts, splice base, message, clock, provenance); bundling it would only move the arity behind a struct built at each call site"
)]
pub(crate) fn land_funnel_transitions(
git_root: &Path,
coord_ref: &str,
tip: &str,
slice: u32,
phase: &str,
ts: &[Transition],
paths_since_verify: Option<&[String]>,
base_tree: Option<&str>,
message: &str,
at: &str,
prov: &Provenance,
) -> anyhow::Result<FunnelLanding> {
let mut record = read_funnel_at(git_root, tip, slice)?;
let stored = record.row(phase).cloned();
let fold = match funnel_machine::fold_transitions(
stored.as_ref(),
phase,
ts,
tip,
paths_since_verify,
at,
) {
Ok(Some(fold)) => fold,
Ok(None) => bail!("land_funnel_transitions: no transitions supplied"),
Err(illegal) => return Ok(FunnelLanding::Illegal(illegal)),
};
if fold.replay {
return Ok(FunnelLanding::Replayed {
position: fold.position,
});
}
if let Some(row) = fold.row {
record.upsert(row);
}
let base = match base_tree {
Some(tree) => tree.to_owned(),
None => tree_of(git_root, tip)?,
};
let tree = splice_record(git_root, &base, slice, &record)?;
match commit_on_behalf(git_root, coord_ref, tip, &tree, message, prov)? {
CommitOutcome::Landed { oid } => Ok(FunnelLanding::Landed {
oid,
position: fold.position,
}),
CommitOutcome::Refused(refusal) => Ok(FunnelLanding::CommitRefused(refusal)),
}
}
#[expect(
clippy::too_many_arguments,
reason = "the projection mirrors PHASE-04's published seam; see `land_funnel_transitions`"
)]
pub(crate) fn land_funnel_transition(
git_root: &Path,
coord_ref: &str,
tip: &str,
slice: u32,
phase: &str,
t: &Transition,
paths_since_verify: Option<&[String]>,
at: &str,
prov: &Provenance,
) -> anyhow::Result<FunnelLanding> {
land_funnel_transitions(
git_root,
coord_ref,
tip,
slice,
phase,
std::slice::from_ref(t),
paths_since_verify,
None,
&funnel_message(slice, phase),
at,
prov,
)
}
fn splice_record(
git_root: &Path,
base_tree: &str,
slice: u32,
record: &FunnelRecord,
) -> anyhow::Result<String> {
Ok(git::tree_with_file(
git_root,
base_tree,
&funnel_machine::funnel_record_path(slice),
&record.render()?,
)?)
}
}
pub(crate) fn land_conclude(
coord_root: &Path,
coord_tip: &str,
slice: u32,
row: &crate::funnel_machine::PhaseRow,
boundary: BoundaryRow,
at: &str,
) -> anyhow::Result<funnel::FunnelLanding> {
let phase = row.id.clone();
let paths = match row.verify.as_ref() {
Some(evidence) => Some(changed_paths_utf8(
coord_root,
&evidence.verified_oid,
coord_tip,
)?),
None => None,
};
let facts = crate::funnel_machine::TransitionFacts {
row: Some(row),
coord_tip,
paths_since_verify: paths.as_deref(),
};
if let Err(illegal) = crate::funnel_machine::attempt_advance(
Some(row.position),
&crate::funnel_machine::Transition::Conclude,
&facts,
) {
return Ok(funnel::FunnelLanding::Illegal(illegal));
}
let tree = compose_boundary_tree(coord_root, coord_tip, slice, boundary)?;
funnel::land_funnel_transitions(
coord_root,
&dispatch_ref(slice),
coord_tip,
slice,
&phase,
&[crate::funnel_machine::Transition::Conclude],
paths.as_deref(),
Some(&tree),
&funnel_message(slice, &phase),
at,
&Provenance::Conclude {
who: dispatch_identity(),
},
)
}
const VERIFY_TREE_DIRTY: &str = "verify-tree-dirty";
const VERIFY_SUITE_UNRESOLVED: &str = "verify-suite-unresolved";
const SUITE_MUTATED_TREE: &str = "suite-mutated-tree";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) enum VerifyOutcome {
Verified { coord_tip: String, suite: String },
VerifyFailed { suite: String, detail: String },
Refused { reason: String, detail: String },
}
fn verify_refused(reason: &str, detail: String) -> VerifyOutcome {
VerifyOutcome::Refused {
reason: reason.to_owned(),
detail,
}
}
pub(crate) fn changed_paths_utf8(root: &Path, a: &str, b: &str) -> anyhow::Result<Vec<String>> {
git::changed_paths(root, a, b)?
.into_iter()
.map(|raw| {
String::from_utf8(raw).map_err(|e| {
anyhow::anyhow!(
"non-utf8 path in {a}..{b} ({:?}) — refusing to reason about it",
e.as_bytes()
)
})
})
.collect()
}
fn tree_describes(root: &Path, tip: &str) -> anyhow::Result<bool> {
Ok(git::index_matches(root, tip)? && git::tree_clean_untracked(root)?.is_clean())
}
fn forward_sync(root: &Path, tip: &str) -> anyhow::Result<Option<String>> {
let run = funnel_run(root, tip)?;
let mut stale = None;
for candidate in &run.candidates {
if git::index_matches(root, candidate)? {
stale = Some(candidate.clone());
break;
}
}
let Some(stale) = stale else {
return Ok(Some(format!(
"the coord index matches no commit in the funnel run at {tip} \
({} candidate(s) probed) — refusing to guess a baseline",
run.candidates.len()
)));
};
let reverse = changed_paths_utf8(root, &stale, tip)?;
let state = git::tree_clean_untracked(root)?;
if !state.untracked.is_empty() {
return Ok(Some(format!(
"unignored untracked path(s) in the coord tree: {}",
state.untracked.join(", ")
)));
}
let mut outside: Vec<String> = state
.staged
.iter()
.chain(state.tracked_dirty.iter())
.filter(|p| !reverse.contains(p))
.cloned()
.collect();
outside.sort();
outside.dedup();
if !outside.is_empty() {
return Ok(Some(format!(
"modified path(s) outside the funnel's reverse set: {}",
outside.join(", ")
)));
}
let mut edited = Vec::new();
for path in &reverse {
if git::worktree_blob_oid(root, path)? != git::blob_oid_at(root, &stale, path)? {
edited.push(path.clone());
}
}
if !edited.is_empty() {
return Ok(Some(format!(
"path(s) carry an edit against the stale baseline {stale}: {}",
edited.join(", ")
)));
}
git::restore_paths(root, tip, &reverse)?;
if !tree_describes(root, tip)? {
return Ok(Some(format!(
"the coord tree still does not describe {tip} after restoring \
{} path(s) — refusing to proceed",
reverse.len()
)));
}
Ok(None)
}
enum SuiteVerdict {
Ran { code: i32 },
Unresolved { detail: String },
}
fn run_verify_suite(root: &Path, cfg: &crate::dtoml::DoctrineToml) -> SuiteVerdict {
let key = cfg.dispatch.verify_suite.as_str();
let unresolved = |why: String| SuiteVerdict::Unresolved {
detail: format!("[dispatch] verify-suite = `{key}`: {why}"),
};
let Some(kind) = crate::verify::CheckKind::from_key(key) else {
return unresolved("names no `[verification]` cadence".to_owned());
};
let argv = match crate::verify::resolve_check(&cfg.verification, kind) {
crate::verify::CheckPlan::Run(argv) => argv,
crate::verify::CheckPlan::Noop(note) => return unresolved(note.to_owned()),
crate::verify::CheckPlan::Empty(kind) => {
return unresolved(format!("`[verification].{}` is empty", kind.key()));
}
};
match crate::verify::run_suite(root, &argv) {
crate::verify::SuiteStatus::Completed { code } => SuiteVerdict::Ran { code },
crate::verify::SuiteStatus::NotFound { program } => {
unresolved(format!("program `{program}` not found"))
}
crate::verify::SuiteStatus::SpawnFailed { program, detail } => {
unresolved(format!("spawning `{program}` failed: {detail}"))
}
crate::verify::SuiteStatus::EmptyArgv => unresolved("resolved to an empty argv".to_owned()),
}
}
pub(crate) fn verify_phase(
coord_root: &Path,
coord_tip: &str,
slice: u32,
phase: &str,
) -> anyhow::Result<VerifyOutcome> {
let record = funnel::read_funnel_at(coord_root, coord_tip, slice)?;
if let Err(illegal) = crate::funnel_machine::preflight(
record.position(phase),
crate::funnel_machine::TransitionKind::Verify,
) {
return Ok(verify_refused(illegal.reason, illegal.to_string()));
}
if let Some(detail) = forward_sync(coord_root, coord_tip)? {
return Ok(verify_refused(VERIFY_TREE_DIRTY, detail));
}
let cfg = crate::dtoml::load_doctrine_toml(coord_root)?;
let suite = cfg.dispatch.verify_suite.clone();
let code = match run_verify_suite(coord_root, &cfg) {
SuiteVerdict::Ran { code } => code,
SuiteVerdict::Unresolved { detail } => {
return Ok(verify_refused(VERIFY_SUITE_UNRESOLVED, detail));
}
};
let mut detail = if code == 0 {
String::new()
} else {
format!("suite `{suite}` exited {code}")
};
let mut status = if code == 0 {
crate::funnel_machine::VerifyStatus::Pass
} else {
crate::funnel_machine::VerifyStatus::Fail
};
if !tree_describes(coord_root, coord_tip)? {
let state = git::tree_clean_untracked(coord_root)?;
status = crate::funnel_machine::VerifyStatus::Fail;
detail = format!(
"{SUITE_MUTATED_TREE}: suite `{suite}` left the coord tree different from \
{coord_tip} (staged: [{}]; modified: [{}]; untracked: [{}])",
state.staged.join(", "),
state.tracked_dirty.join(", "),
state.untracked.join(", ")
);
}
let evidence = crate::funnel_machine::VerifyEvidence {
status,
verified_oid: coord_tip.to_owned(),
suite: suite.clone(),
at: crate::clock::now_timestamp()?,
};
let at = evidence.at.clone();
match funnel::land_funnel_transition(
coord_root,
&dispatch_ref(slice),
coord_tip,
slice,
phase,
&crate::funnel_machine::Transition::Verify { evidence },
None,
&at,
&Provenance::Conclude {
who: dispatch_identity(),
},
)? {
funnel::FunnelLanding::Landed { oid, .. } => {
Ok(verdict_outcome(status, suite, detail, oid))
}
funnel::FunnelLanding::Replayed { .. } => {
Ok(verdict_outcome(status, suite, detail, coord_tip.to_owned()))
}
funnel::FunnelLanding::Illegal(illegal) => {
Ok(verify_refused(illegal.reason, illegal.to_string()))
}
funnel::FunnelLanding::CommitRefused(refusal) => {
Ok(verify_refused(refusal.token(), String::new()))
}
}
}
fn verdict_outcome(
status: crate::funnel_machine::VerifyStatus,
suite: String,
detail: String,
coord_tip: String,
) -> VerifyOutcome {
match status {
crate::funnel_machine::VerifyStatus::Pass => VerifyOutcome::Verified { coord_tip, suite },
crate::funnel_machine::VerifyStatus::Fail => VerifyOutcome::VerifyFailed { suite, detail },
}
}
fn run_verify(path: Option<PathBuf>, slice: u32, phase: &str) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let dref = dispatch_ref(slice);
let tip = resolve_commit(&root, &dref)?
.with_context(|| format!("verify: {dref} does not resolve to a commit"))?;
let out = verify_phase(&root, &tip, slice, phase)?;
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&out)?)?;
match out {
VerifyOutcome::Verified { .. } => Ok(()),
VerifyOutcome::VerifyFailed { suite, .. } => bail!("verify: suite `{suite}` did not pass"),
VerifyOutcome::Refused { reason, .. } => bail!("verify: {reason}"),
}
}
pub(crate) const TOOL_DISPATCH_IMPORT: &str = "dispatch_import";
pub(crate) const TOOL_DISPATCH_CONCLUDE_PHASE: &str = "dispatch_conclude_phase";
pub(crate) const TOOL_DISPATCH_REAP: &str = "dispatch_reap";
const VERIFY_CLI: &str = "doctrine dispatch verify";
const STATUS_CLI: &str = "doctrine dispatch status";
const MISSING_SPAWN: &str = "spawn record";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NextKind {
Spawn,
AwaitWorker,
Import,
Verify,
TriageVerifyFailure,
ReverifyStale,
Conclude,
Reap,
AllReaped,
}
impl NextKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
NextKind::Spawn => "spawn",
NextKind::AwaitWorker => "await-worker",
NextKind::Import => "import",
NextKind::Verify => "verify",
NextKind::TriageVerifyFailure => "triage-verify-failure",
NextKind::ReverifyStale => "reverify-stale",
NextKind::Conclude => "conclude",
NextKind::Reap => "reap",
NextKind::AllReaped => "all-reaped",
}
}
}
impl serde::Serialize for NextKind {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct NextCore {
pub(crate) kind: NextKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) command: Option<String>,
pub(crate) detail: String,
}
const fn next_kind(expected: crate::funnel_machine::Expected, at: Option<Position>) -> NextKind {
use crate::funnel_machine::Expected;
match expected {
Expected::Spawn => NextKind::Spawn,
Expected::AwaitWorker => NextKind::AwaitWorker,
Expected::Import => NextKind::Import,
Expected::Verify => match at {
Some(Position::Verified) => NextKind::ReverifyStale,
_ => NextKind::Verify,
},
Expected::Triage => NextKind::TriageVerifyFailure,
Expected::Conclude => NextKind::Conclude,
Expected::Reap => NextKind::Reap,
Expected::Terminal => NextKind::AllReaped,
}
}
#[derive(Debug)]
pub(crate) struct PhasePrescription<'a> {
pub(crate) row: &'a crate::funnel_machine::PhaseRow,
pub(crate) expected: crate::funnel_machine::Expected,
}
#[derive(Debug)]
pub(crate) enum NextSelection<'a> {
Prescribed {
kind: NextKind,
row: &'a crate::funnel_machine::PhaseRow,
},
Spawn { phase: &'a str },
AllReaped,
}
const fn is_runnable(expected: crate::funnel_machine::Expected) -> bool {
use crate::funnel_machine::Expected;
matches!(
expected,
Expected::Import | Expected::Verify | Expected::Conclude | Expected::Reap
)
}
fn select_next<'a>(
mid: &'a [PhasePrescription<'a>],
recorded: &BTreeSet<&str>,
readiness: &'a [String],
) -> NextSelection<'a> {
let prescribed = |p: &'a PhasePrescription<'a>| NextSelection::Prescribed {
kind: next_kind(p.expected, Some(p.row.position)),
row: p.row,
};
if let Some(p) = mid
.iter()
.find(|p| p.expected == crate::funnel_machine::Expected::Triage)
{
return prescribed(p);
}
if let Some(p) = mid.iter().find(|p| is_runnable(p.expected)) {
return prescribed(p);
}
if let Some(p) = mid.first() {
return prescribed(p);
}
match readiness.iter().find(|id| !recorded.contains(id.as_str())) {
Some(phase) => NextSelection::Spawn { phase },
None => NextSelection::AllReaped,
}
}
fn next_command(
kind: NextKind,
slice: u32,
coord_tip: &str,
row: Option<&crate::funnel_machine::PhaseRow>,
) -> Result<Option<String>, &'static str> {
let phase = row.map_or("", |r| r.id.as_str());
let spawn = || row.and_then(|r| r.spawn.as_ref()).ok_or(MISSING_SPAWN);
Ok(Some(match kind {
NextKind::Spawn
| NextKind::AwaitWorker
| NextKind::TriageVerifyFailure
| NextKind::AllReaped => return Ok(None),
NextKind::Import => format!(
"{TOOL_DISPATCH_IMPORT}{{slice: {slice}, name: \"{}\"}}",
spawn()?.fork
),
NextKind::Verify | NextKind::ReverifyStale => {
format!("{VERIFY_CLI} --slice {slice} --phase {phase}")
}
NextKind::Conclude => format!(
"{TOOL_DISPATCH_CONCLUDE_PHASE}{{slice: {slice}, phase: \"{phase}\", \
code_start: \"{}\", code_end: \"{coord_tip}\"}}",
spawn()?.base_oid
),
NextKind::Reap => format!(
"{TOOL_DISPATCH_REAP}{{slice: {slice}, name: \"{}\"}}",
spawn()?.fork
),
}))
}
fn rung_detail(
kind: NextKind,
phase: Option<&str>,
row: Option<&crate::funnel_machine::PhaseRow>,
) -> String {
let p = phase.unwrap_or("(no phase)");
let at = row.map_or_else(String::new, |r| format!(" at `{}`", r.position.as_str()));
match kind {
NextKind::Spawn => format!(
"{p} has no funnel row yet — spawn its worker. No command literal: spawn \
mechanics are ARM-SPECIFIC (claude: `dispatch arm-spawn` then the `Agent` \
tool; codex/pi: `worktree fork --worker` then a subprocess) and this oracle \
is deliberately arm-agnostic. Hand off to the arm skill."
),
NextKind::AwaitWorker => format!(
"{p}{at} — the fork is armed; the next event is the worker's own commit, not \
an orchestrator verb. Wait for the worker to return."
),
NextKind::Import => format!(
"{p}{at} — the worker's commit is not on the coordination tip yet. MCP owns \
this verb; there is no CLI equivalent."
),
NextKind::Verify => format!(
"{p}{at} — the delta is on the tip with no verify evidence. The CLI owns this \
verb: run the line in a shell."
),
NextKind::ReverifyStale => format!(
"{p}{at} — the stored pass evidence no longer describes the tip \
(non-funnel-record paths changed since `verified_oid`). Re-run the suite; \
the CLI owns this verb."
),
NextKind::TriageVerifyFailure => format!(
"{p}{at} — the stored verify evidence is RED. Re-running the suite unchanged \
is not the next move: triage the failure. This is a judgment beat, so it \
carries no command, and it outranks every runnable phase because the verify \
suite is coord-tree-global."
),
NextKind::Conclude => format!(
"{p}{at} — pass evidence describes the tip modulo the funnel record. MCP owns \
this verb."
),
NextKind::Reap => format!(
"{p}{at} — the phase boundary is recorded and the fork is spent. MCP owns this \
verb; never a raw `git worktree remove`."
),
NextKind::AllReaped => format!(
"every phase in the funnel is reaped and none remains to spawn — the phase \
funnel is complete. `next` answers the phase funnel only; for the slice \
lifecycle beyond it (prepare-review, audit, admission, close) run \
`{STATUS_CLI} --slice <N>`."
),
}
}
fn in_flight(mid: &[PhasePrescription<'_>], chosen: Option<&str>) -> String {
let others: Vec<String> = mid
.iter()
.filter(|p| Some(p.row.id.as_str()) != chosen)
.map(|p| {
format!(
"{} at `{}` ({})",
p.row.id,
p.row.position.as_str(),
p.expected.as_str()
)
})
.collect();
if others.is_empty() {
String::new()
} else {
format!(" In flight: {}.", others.join("; "))
}
}
fn render_next(
slice: u32,
coord_tip: &str,
selection: &NextSelection<'_>,
mid: &[PhasePrescription<'_>],
) -> NextCore {
let (kind, phase, row) = match *selection {
NextSelection::Prescribed { kind, row } => (kind, Some(row.id.clone()), Some(row)),
NextSelection::Spawn { phase } => (NextKind::Spawn, Some(phase.to_owned()), None),
NextSelection::AllReaped => (NextKind::AllReaped, None, None),
};
let (command, missing) = match next_command(kind, slice, coord_tip, row) {
Ok(command) => (command, String::new()),
Err(field) => (
None,
format!(
" No command: the funnel row carries no {field}, so the literal cannot be \
parameterised — heal the row (re-drive the import) before acting."
),
),
};
let detail = format!(
"{}{missing}{}",
rung_detail(kind, phase.as_deref(), row),
in_flight(mid, phase.as_deref())
);
NextCore {
kind,
phase,
command,
detail,
}
}
pub(crate) fn next_core(root: &Path, coord_tip: &str, slice: u32) -> anyhow::Result<NextCore> {
let mut record = funnel::read_funnel_at(root, coord_tip, slice)?;
record.rows.sort_by(|a, b| a.id.cmp(&b.id));
let recorded: BTreeSet<&str> = record.rows.iter().map(|r| r.id.as_str()).collect();
let mut paths: Vec<Option<Vec<String>>> = Vec::new();
for row in &record.rows {
if row.position == Position::Reaped {
continue;
}
paths.push(match row.verify.as_ref() {
Some(evidence) => Some(changed_paths_utf8(root, &evidence.verified_oid, coord_tip)?),
None => None,
});
}
let mid: Vec<PhasePrescription<'_>> = record
.rows
.iter()
.filter(|row| row.position != Position::Reaped)
.zip(paths.iter())
.map(|(row, paths)| {
let facts = crate::funnel_machine::TransitionFacts {
row: Some(row),
coord_tip,
paths_since_verify: paths.as_deref(),
};
PhasePrescription {
expected: crate::funnel_machine::expected_next(Some(row.position), &facts),
row,
}
})
.collect();
let readiness = if mid.is_empty() {
compute_next_phases(&plan_next_rows(root, slice)?)
} else {
Vec::new()
};
let selection = select_next(&mid, &recorded, &readiness);
Ok(render_next(slice, coord_tip, &selection, &mid))
}
fn render_next_human(core: &NextCore) -> String {
let phase = core
.phase
.as_deref()
.map_or_else(String::new, |p| format!(" — {p}"));
let run = core
.command
.as_deref()
.map_or_else(String::new, |c| format!("run: {c}\n"));
format!(
"next: {}{phase}\n{run}why: {}\n",
core.kind.as_str(),
core.detail.trim()
)
}
fn run_next(path: Option<PathBuf>, slice: u32, json: bool) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let dref = dispatch_ref(slice);
let tip = resolve_commit(&root, &dref)?
.with_context(|| format!("next: {dref} does not resolve to a commit"))?;
let core = next_core(&root, &tip, slice)?;
if json {
writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&core)?)?;
} else {
write!(io::stdout(), "{}", render_next_human(&core))?;
}
Ok(())
}
pub(crate) fn run_create_fork_and_record() -> anyhow::Result<()> {
record_spawn(&crate::worktree::run_create_fork()?);
Ok(())
}
fn record_spawn(created: &crate::worktree::CreatedFork) {
let Some(spawn) = created.spawn.as_ref() else {
return;
};
if let Err(cause) = land_spawn_row(spawn) {
drop(writeln!(
io::stderr(),
"warning: spawn row not landed for {} ({cause:#}) — the fork is live; import heals the row forward",
spawn.fork
));
}
}
fn land_spawn_row(spawn: &crate::worktree::SpawnFacts) -> anyhow::Result<()> {
let coord_ref = dispatch_ref(spawn.slice);
let tip = resolve_commit(&spawn.coord, &coord_ref)?
.with_context(|| format!("spawn row: {coord_ref} does not resolve to a commit"))?;
let t = crate::funnel_machine::Transition::Spawn {
fork: spawn.fork.clone(),
base_oid: spawn.base_oid.clone(),
};
match funnel::land_funnel_transition(
&spawn.coord,
&coord_ref,
&tip,
spawn.slice,
&spawn.phase,
&t,
None,
&crate::clock::now_timestamp()?,
&Provenance::Conclude {
who: dispatch_identity(),
},
)? {
funnel::FunnelLanding::Landed { .. } | funnel::FunnelLanding::Replayed { .. } => Ok(()),
funnel::FunnelLanding::Illegal(illegal) => bail!("{illegal}"),
funnel::FunnelLanding::CommitRefused(refusal) => bail!("spawn row: {}", refusal.token()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::SCHEMA_PLAN_OVERVIEW;
use std::fs;
use std::path::Path;
fn git_run(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn disp() -> Provenance {
Provenance::Conclude {
who: Identity {
name: "dispatch".to_string(),
email: "dispatch@doctrine".to_string(),
},
}
}
fn primary_with_coord(slice: u32) -> (tempfile::TempDir, PathBuf, PathBuf, String, String) {
let tmp = tempfile::tempdir().unwrap();
let primary = fs::canonicalize(tmp.path()).unwrap().join("primary");
fs::create_dir_all(&primary).unwrap();
git_run(&primary, &["init", "-q", "-b", "main"]);
git_run(&primary, &["config", "user.email", "t@t"]);
git_run(&primary, &["config", "user.name", "t"]);
fs::write(primary.join("seed"), "base\n").unwrap();
git_run(&primary, &["add", "-A"]);
git_run(&primary, &["commit", "-q", "-m", "base"]);
let base = git_run(&primary, &["rev-parse", "HEAD^{commit}"]);
let base_tree = git_run(&primary, &["rev-parse", "HEAD^{tree}"]);
let coord = fs::canonicalize(tmp.path()).unwrap().join("coord");
git_run(
&primary,
&[
"worktree",
"add",
"-q",
"-b",
&format!("dispatch/{slice:03}"),
coord.to_str().unwrap(),
&base,
],
);
let coord = fs::canonicalize(&coord).unwrap();
(tmp, primary, coord, base, base_tree)
}
impl CommitOutcome {
fn token_is(&self, want: &str) -> bool {
matches!(self, CommitOutcome::Refused(r) if r.token() == want)
}
}
fn boundaries_at(coord: &Path, oid: &str) -> Boundaries {
let text = git_run(
coord,
&[
"show",
&format!("{oid}:.doctrine/dispatch/199/boundaries.toml"),
],
);
Boundaries::parse(&text).unwrap()
}
#[test]
fn land_boundary_row_upserts_by_phase() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(199);
let coord_ref = dispatch_ref(199);
let mk = |phase: &str, s: &str, e: &str| BoundaryRow {
phase: phase.to_owned(),
code_start_oid: s.to_owned(),
code_end_oid: e.to_owned(),
provenance: BoundaryProvenance::Funnel,
};
let land = |tip: &str, row: BoundaryRow| match land_boundary_row(
&coord,
&coord_ref,
tip,
199,
row,
&disp(),
)
.unwrap()
{
CommitOutcome::Landed { oid } => oid,
other => panic!("expected Landed, got {other:?}"),
};
let tip1 = land(&base, mk("PHASE-01", "aaa", "bbb"));
let tip2 = land(&tip1, mk("PHASE-02", "ccc", "ddd"));
let rows2 = boundaries_at(&coord, &tip2).rows;
assert_eq!(rows2.len(), 2, "append, never duplicate");
assert_eq!(rows2[0].phase, "PHASE-01");
assert_eq!(rows2[0].code_end_oid, "bbb", "existing row preserved");
assert_eq!(rows2[1].phase, "PHASE-02", "new row lands at the tail");
assert_eq!(rows2[1].code_end_oid, "ddd");
let tip3 = land(&tip2, mk("PHASE-01", "aaa", "zzz"));
let rows3 = boundaries_at(&coord, &tip3).rows;
assert_eq!(rows3.len(), 2, "replace in place, never a second PHASE-01");
assert_eq!(rows3[0].phase, "PHASE-01");
assert_eq!(
rows3[0].code_end_oid, "zzz",
"PHASE-01 row updated in place"
);
assert_eq!(rows3[1].phase, "PHASE-02", "sibling preserved, order kept");
assert_eq!(rows3[1].code_end_oid, "ddd");
}
mod funnel_record {
use super::super::funnel::{
FunnelLanding, FunnelRecord, LandingVerdict, land_funnel_transition, read_funnel_at,
resolve_landing,
};
use super::{disp, git_run, primary_with_coord};
use crate::dispatch::{CommitRefusal, dispatch_ref};
use crate::funnel_machine::{
self, PhaseRow, Position, StampRecord, Transition, VerifyEvidence, VerifyStatus,
};
use std::path::Path;
const SLICE: u32 = 199;
const PHASE: &str = "PHASE-01";
const AT: &str = "2026-07-25T09:00:00Z";
fn spawn(fork_tip: &str) -> Transition {
Transition::Spawn {
fork: "dispatch/worker-a".to_owned(),
base_oid: fork_tip.to_owned(),
}
}
fn land(coord: &Path, tip: &str, t: &Transition) -> String {
match land_funnel_transition(
coord,
&dispatch_ref(SLICE),
tip,
SLICE,
PHASE,
t,
None,
AT,
&disp(),
)
.unwrap()
{
FunnelLanding::Landed { oid, .. } => oid,
other => panic!("expected Landed, got {other:?}"),
}
}
#[test]
fn funnel_record_round_trips_the_full_row_shape() {
let row = PhaseRow {
id: PHASE.to_owned(),
position: Position::Verified,
updated_at: AT.to_owned(),
spawn: None,
worker_commit: None,
import: Some(funnel_machine::ImportRecord {
fork_tip: "deadbeef".to_owned(),
onto: "cafe".to_owned(),
at: AT.to_owned(),
}),
verify: Some(VerifyEvidence {
status: VerifyStatus::Pass,
verified_oid: "cafe".to_owned(),
suite: "gate".to_owned(),
at: AT.to_owned(),
}),
conclude: Some(StampRecord { at: AT.to_owned() }),
reap: None,
};
let record = FunnelRecord {
schema: 1,
rows: vec![row.clone()],
};
let text = record.render().unwrap();
assert!(text.contains("schema = 1"), "{text}");
assert!(text.contains("[[phase]]"), "{text}");
assert!(text.contains("position = \"verified\""), "{text}");
assert!(text.contains("[phase.import]"), "{text}");
assert!(
!text.contains("import_oid"),
"no import_oid: the row rides the import commit's own tree ({text})"
);
let back = FunnelRecord::parse(&text).unwrap();
assert_eq!(back, record);
assert_eq!(back.row(PHASE), Some(&row));
assert_eq!(back.position(PHASE), Some(Position::Verified));
}
fn fork_row(
phase: &str,
fork: &str,
position: Position,
fork_tip: Option<&str>,
) -> PhaseRow {
PhaseRow {
id: phase.to_owned(),
position,
updated_at: AT.to_owned(),
spawn: Some(funnel_machine::SpawnRecord {
fork: fork.to_owned(),
base_oid: "base".to_owned(),
at: AT.to_owned(),
}),
worker_commit: None,
import: fork_tip.map(|tip| funnel_machine::ImportRecord {
fork_tip: tip.to_owned(),
onto: "onto".to_owned(),
at: AT.to_owned(),
}),
verify: None,
conclude: None,
reap: None,
}
}
fn commit_record(coord: &Path, rows: Vec<PhaseRow>) -> String {
let record = FunnelRecord { schema: 1, rows };
let path = funnel_machine::funnel_record_path(SLICE);
let file = coord.join(&path);
std::fs::create_dir_all(file.parent().expect("record dir")).unwrap();
std::fs::write(&file, record.render().unwrap()).unwrap();
git_run(coord, &["add", &path]);
git_run(coord, &["commit", "-q", "-m", "funnel record fixture"]);
git_run(coord, &["rev-parse", "HEAD"])
}
const RESOLVER_FORK: &str = "dispatch/agent-r";
#[test]
fn a_concluded_row_at_the_imported_tip_proves_the_fork_landed() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
git_run(&coord, &["branch", RESOLVER_FORK, &base]);
let tip = commit_record(
&coord,
vec![fork_row(
PHASE,
RESOLVER_FORK,
Position::Concluded,
Some(&base),
)],
);
assert_eq!(
resolve_landing(&coord, &tip, SLICE, RESOLVER_FORK).unwrap(),
LandingVerdict::Landed
);
let replay = commit_record(
&coord,
vec![fork_row(
PHASE,
RESOLVER_FORK,
Position::Reaped,
Some(&base),
)],
);
assert_eq!(
resolve_landing(&coord, &replay, SLICE, RESOLVER_FORK).unwrap(),
LandingVerdict::Landed
);
}
#[test]
fn a_branch_advanced_past_the_imported_tip_is_not_proven() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let advanced = git_run(
&coord,
&[
"commit-tree",
&format!("{base}^{{tree}}"),
"-p",
&base,
"-m",
"later work",
],
);
git_run(&coord, &["branch", RESOLVER_FORK, &advanced]);
let tip = commit_record(
&coord,
vec![fork_row(
PHASE,
RESOLVER_FORK,
Position::Concluded,
Some(&base),
)],
);
assert_eq!(
resolve_landing(&coord, &tip, SLICE, RESOLVER_FORK).unwrap(),
LandingVerdict::NotProven
);
}
#[test]
fn two_rows_naming_one_fork_are_ambiguous_and_never_bind_the_first() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
git_run(&coord, &["branch", RESOLVER_FORK, &base]);
let tip = commit_record(
&coord,
vec![
fork_row(PHASE, RESOLVER_FORK, Position::Concluded, Some(&base)),
fork_row("PHASE-02", RESOLVER_FORK, Position::Spawned, None),
],
);
assert_eq!(
resolve_landing(&coord, &tip, SLICE, RESOLVER_FORK).unwrap(),
LandingVerdict::Ambiguous
);
}
#[test]
fn a_row_still_at_imported_is_not_proven() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
git_run(&coord, &["branch", RESOLVER_FORK, &base]);
let tip = commit_record(
&coord,
vec![fork_row(
PHASE,
RESOLVER_FORK,
Position::Imported,
Some(&base),
)],
);
assert_eq!(
resolve_landing(&coord, &tip, SLICE, RESOLVER_FORK).unwrap(),
LandingVerdict::NotProven
);
}
#[test]
fn a_fork_no_row_records_is_no_row() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
git_run(&coord, &["branch", RESOLVER_FORK, &base]);
let tip = commit_record(
&coord,
vec![fork_row(
PHASE,
"dispatch/agent-other",
Position::Concluded,
Some(&base),
)],
);
assert_eq!(
resolve_landing(&coord, &tip, SLICE, RESOLVER_FORK).unwrap(),
LandingVerdict::NoRow
);
assert_eq!(
resolve_landing(&coord, &base, SLICE, RESOLVER_FORK).unwrap(),
LandingVerdict::NoRow
);
}
#[test]
fn read_funnel_at_yields_the_empty_record_when_absent() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let record = read_funnel_at(&coord, &base, SLICE).unwrap();
assert_eq!(record, FunnelRecord::default());
assert_eq!(
record.schema, 1,
"an absent record still carries the schema"
);
assert!(record.rows.is_empty());
assert_eq!(record.position(PHASE), None, "no row ⇔ pre-spawn");
}
#[test]
fn the_sole_writer_lands_approved_transitions_and_refuses_illegal_ones() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let refused = land_funnel_transition(
&coord,
&dispatch_ref(SLICE),
&base,
SLICE,
PHASE,
&Transition::Import {
fork_tip: "aaa".to_owned(),
onto: base.clone(),
},
None,
AT,
&disp(),
)
.unwrap();
match refused {
FunnelLanding::Illegal(illegal) => assert_eq!(illegal.reason, "not-spawned"),
other => panic!("expected Illegal, got {other:?}"),
}
assert_eq!(
git_run(&coord, &["rev-parse", "HEAD"]),
base,
"a refusal leaves the coord tip byte-unchanged"
);
let tip1 = land(&coord, &base, &spawn(&base));
let tip2 = land(
&coord,
&tip1,
&Transition::RecordWorkerCommit {
fork_tip: "forktip".to_owned(),
},
);
let record = read_funnel_at(&coord, &tip2, SLICE).unwrap();
assert_eq!(record.position(PHASE), Some(Position::WorkerCommitted));
let row = record.row(PHASE).unwrap();
assert_eq!(row.spawn.as_ref().unwrap().base_oid, base, "spawn survives");
assert_eq!(row.worker_commit.as_ref().unwrap().fork_tip, "forktip");
assert_eq!(
git_run(&coord, &["rev-list", "--count", &format!("{base}..{tip2}")]),
"2",
"exactly one commit per landed transition"
);
}
#[test]
fn a_replay_lands_nothing() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let tip1 = land(&coord, &base, &spawn(&base));
let outcome = land_funnel_transition(
&coord,
&dispatch_ref(SLICE),
&tip1,
SLICE,
PHASE,
&spawn(&base),
None,
AT,
&disp(),
)
.unwrap();
assert_eq!(
outcome,
FunnelLanding::Replayed {
position: Position::Spawned
}
);
assert_eq!(
git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]),
tip1,
"a replay leaves the ref where it was"
);
}
#[test]
fn a_lost_ref_race_retry_lands_exactly_one_transition() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let tip1 = land(&coord, &base, &spawn(&base));
let raced = land_funnel_transition(
&coord,
&dispatch_ref(SLICE),
&base,
SLICE,
PHASE,
&spawn(&base),
None,
AT,
&disp(),
)
.unwrap();
assert_eq!(
raced,
FunnelLanding::CommitRefused(CommitRefusal::LostRefRace)
);
assert_eq!(
git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]),
tip1,
"the refused race leaves the tip untouched"
);
let recovered = land_funnel_transition(
&coord,
&dispatch_ref(SLICE),
&tip1,
SLICE,
PHASE,
&spawn(&base),
None,
AT,
&disp(),
)
.unwrap();
assert_eq!(
recovered,
FunnelLanding::Replayed {
position: Position::Spawned
}
);
assert_eq!(
git_run(&coord, &["rev-list", "--count", &format!("{base}..{tip1}")]),
"1",
"exactly one transition landed across the race + retry"
);
let record = read_funnel_at(&coord, &tip1, SLICE).unwrap();
assert_eq!(record.rows.len(), 1, "one row, not a duplicate");
}
#[test]
fn the_landed_record_lives_at_the_single_sourced_path() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let tip = land(&coord, &base, &spawn(&base));
let listed = git_run(&coord, &["ls-tree", "-r", "--name-only", &tip]);
let path = funnel_machine::funnel_record_path(SLICE);
assert!(listed.contains(&path), "{path} not in {listed}");
assert!(listed.contains("seed"), "the base tree is preserved");
}
use super::super::funnel::land_funnel_transitions;
use crate::boundary::{BoundaryRow, Provenance as BoundaryProvenance};
use crate::dispatch::{compose_boundary_tree, funnel_message};
fn heal_ladder(base: &str, fork_tip: &str, onto: &str) -> Vec<Transition> {
vec![
spawn(base),
Transition::RecordWorkerCommit {
fork_tip: fork_tip.to_owned(),
},
Transition::Import {
fork_tip: fork_tip.to_owned(),
onto: onto.to_owned(),
},
]
}
fn land_many(
coord: &Path,
tip: &str,
ts: &[Transition],
base_tree: Option<&str>,
) -> FunnelLanding {
land_funnel_transitions(
coord,
&dispatch_ref(SLICE),
tip,
SLICE,
PHASE,
ts,
None,
base_tree,
&funnel_message(SLICE, PHASE),
AT,
&disp(),
)
.unwrap()
}
#[test]
fn a_multi_transition_fold_lands_exactly_one_commit() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let ladder = heal_ladder(&base, "forktip", &base);
let tip = match land_many(&coord, &base, &ladder, None) {
FunnelLanding::Landed { oid, position } => {
assert_eq!(position, Position::Imported, "the fold's LAST position");
oid
}
other => panic!("expected Landed, got {other:?}"),
};
assert_eq!(
git_run(&coord, &["rev-list", "--count", &format!("{base}..{tip}")]),
"1",
"one commit for the whole healed prefix"
);
let row = read_funnel_at(&coord, &tip, SLICE)
.unwrap()
.row(PHASE)
.cloned()
.expect("the healed row");
assert_eq!(row.position, Position::Imported);
assert_eq!(row.spawn.expect("spawn").base_oid, base);
assert_eq!(
row.worker_commit.expect("worker commit").fork_tip,
"forktip"
);
assert_eq!(row.import.expect("import").fork_tip, "forktip");
}
#[test]
fn a_mid_fold_refusal_lands_nothing_at_all() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let ts = vec![spawn(&base), Transition::Conclude];
match land_many(&coord, &base, &ts, None) {
FunnelLanding::Illegal(illegal) => {
assert_eq!(illegal.attempted, funnel_machine::TransitionKind::Conclude);
assert_eq!(illegal.current, Some(Position::Spawned));
}
other => panic!("expected Illegal, got {other:?}"),
}
assert_eq!(
git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]),
base,
"the tip is byte-unmoved"
);
assert!(
read_funnel_at(&coord, &base, SLICE)
.unwrap()
.rows
.is_empty(),
"not even the legal prefix was written"
);
}
#[test]
fn an_all_replay_fold_lands_nothing_and_reports_replayed() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let ladder = heal_ladder(&base, "forktip", &base);
let tip = match land_many(&coord, &base, &ladder, None) {
FunnelLanding::Landed { oid, .. } => oid,
other => panic!("expected Landed, got {other:?}"),
};
let retry = vec![Transition::Import {
fork_tip: "forktip".to_owned(),
onto: "a-freshly-resolved-tip".to_owned(),
}];
assert_eq!(
land_many(&coord, &tip, &retry, None),
FunnelLanding::Replayed {
position: Position::Imported
}
);
assert_eq!(
git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]),
tip,
"a replay leaves the ref where it was"
);
}
#[test]
fn a_caller_supplied_base_tree_carries_a_second_payload_in_the_same_commit() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let row = BoundaryRow {
phase: PHASE.to_owned(),
code_start_oid: base.clone(),
code_end_oid: base.clone(),
provenance: BoundaryProvenance::Funnel,
};
let tree = compose_boundary_tree(&coord, &base, SLICE, row).unwrap();
let tip = match land_many(&coord, &base, &[spawn(&base)], Some(&tree)) {
FunnelLanding::Landed { oid, .. } => oid,
other => panic!("expected Landed, got {other:?}"),
};
assert_eq!(
git_run(&coord, &["rev-list", "--count", &format!("{base}..{tip}")]),
"1",
"boundary ⊕ position in ONE commit"
);
let listed = git_run(&coord, &["ls-tree", "-r", "--name-only", &tip]);
assert!(
listed.contains(&funnel_machine::funnel_record_path(SLICE)),
"the funnel record rode the commit: {listed}"
);
assert!(
listed.contains(".doctrine/dispatch/199/boundaries.toml"),
"so did the boundary: {listed}"
);
}
use crate::dispatch::{land_spawn_row, record_spawn};
use crate::worktree::{CreatedFork, SpawnFacts};
use std::path::PathBuf;
fn facts(coord: &Path, base: &str) -> SpawnFacts {
SpawnFacts {
coord: coord.to_path_buf(),
slice: SLICE,
phase: PHASE.to_owned(),
fork: "dispatch/agent-real".to_owned(),
base_oid: base.to_owned(),
}
}
#[test]
fn the_spawn_row_lands_post_act_with_the_real_fork_name_and_base_oid() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
land_spawn_row(&facts(&coord, &base)).expect("the row lands");
let tip = git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]);
let row = read_funnel_at(&coord, &tip, SLICE)
.unwrap()
.row(PHASE)
.cloned()
.expect("a row was landed");
assert_eq!(row.position, Position::Spawned);
let spawn = row.spawn.expect("the spawn provenance");
assert_eq!(spawn.fork, "dispatch/agent-real", "the REAL fork name");
assert_eq!(spawn.base_oid, base, "the base it actually forked at");
land_spawn_row(&facts(&coord, &base)).expect("a re-drive replays");
assert_eq!(
git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]),
tip,
"the replay landed no second commit"
);
}
#[test]
fn a_failed_spawn_landing_never_aborts_the_spawn() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let mut broken = facts(&coord, &base);
broken.slice = 998;
assert!(
land_spawn_row(&broken).is_err(),
"the fixture really does fail to land"
);
record_spawn(&CreatedFork {
dir: PathBuf::from("/created/path"),
spawn: Some(broken),
});
let tip = git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]);
assert_eq!(tip, base, "nothing was landed anywhere");
}
#[test]
fn a_passthrough_or_unbound_fork_gets_no_row() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
record_spawn(&CreatedFork {
dir: PathBuf::from("/created/path"),
spawn: None,
});
assert_eq!(
git_run(&coord, &["rev-parse", &dispatch_ref(SLICE)]),
base,
"no row, no commit"
);
}
}
#[test]
fn plan_phases_orders_by_phase_ordinal_not_row_order() {
let (_tmp, primary, _coord, base, _bt) = primary_with_coord(85);
let trunk_base = base.clone();
let commit = |file: &str, content: &str, msg: &str| -> String {
fs::write(primary.join(file), content).unwrap();
git_run(&primary, &["add", "-A"]);
git_run(&primary, &["commit", "-q", "-m", msg]);
git_run(&primary, &["rev-parse", "HEAD^{commit}"])
};
let c1 = commit("p1.txt", "one", "c1");
let c2 = commit("p2.txt", "two", "c2");
let c3 = commit("p3.txt", "three", "c3");
let c4 = commit("p4.txt", "four", "c4");
let c5 = commit("p5.txt", "five", "c5");
let row = |phase: &str, end: &str| BoundaryRow {
phase: phase.to_owned(),
code_start_oid: trunk_base.clone(),
code_end_oid: end.to_owned(),
provenance: BoundaryProvenance::Funnel,
};
let scrambled = vec![
row("PHASE-01", &c1),
row("PHASE-02", &c2),
row("PHASE-04", &c4),
row("PHASE-05", &c5),
row("PHASE-03", &c3),
];
let boundaries = Boundaries {
rows: scrambled.clone(),
};
let mut planned: Vec<Planned> = Vec::new();
plan_phases(&primary, "085", &trunk_base, &boundaries, &mut planned)
.expect("plan_phases succeeds on the scrambled ledger");
let commit_for = |nn: &str| -> String {
let target = format!("{PHASE_REF_PREFIX}085-{nn}");
planned
.iter()
.find(|p| p.target_ref == target)
.unwrap_or_else(|| panic!("no planned cut for {nn}"))
.commit_oid
.clone()
};
let parent_of = |oid: &str| git_run(&primary, &["rev-parse", &format!("{oid}^")]);
assert_eq!(parent_of(&commit_for("01")), trunk_base, "01 ← trunk_base");
assert_eq!(parent_of(&commit_for("02")), commit_for("01"), "02 ← 01");
assert_eq!(
parent_of(&commit_for("03")),
commit_for("02"),
"03 ← 02 (not the scrambled predecessor 05)"
);
assert_eq!(
parent_of(&commit_for("04")),
commit_for("03"),
"04 ← 03 (not the scrambled predecessor 02)"
);
assert_eq!(parent_of(&commit_for("05")), commit_for("04"), "05 ← 04");
assert_eq!(
boundaries.rows, scrambled,
"plan_phases must not reorder the input ledger rows"
);
}
#[test]
fn commit_on_behalf_happy_lands_one_non_merge_commit() {
let (_tmp, _primary, coord, base, base_tree) = primary_with_coord(199);
let tree = git::tree_with_file(&coord, &base_tree, "added.txt", "hi\n").unwrap();
let out = commit_on_behalf(
&coord,
"refs/heads/dispatch/199",
&base,
&tree,
"m",
&disp(),
)
.unwrap();
let oid = match out {
CommitOutcome::Landed { oid } => oid,
other => panic!("expected Landed, got {other:?}"),
};
assert_eq!(
git_run(&coord, &["rev-parse", "refs/heads/dispatch/199"]),
oid
);
let parents = git_run(&coord, &["rev-list", "--parents", "-n", "1", &oid]);
let cols: Vec<&str> = parents.split_whitespace().collect();
assert_eq!(cols.len(), 2, "exactly one parent: {parents}");
assert_eq!(cols[1], base, "C^ == expected_old");
assert_eq!(
git_run(&coord, &["rev-parse", &format!("{oid}^{{tree}}")]),
tree
);
}
#[test]
fn commit_on_behalf_empty_delta_refuses_and_leaves_the_tip() {
let (_tmp, _primary, coord, base, base_tree) = primary_with_coord(199);
let out = commit_on_behalf(
&coord,
"refs/heads/dispatch/199",
&base,
&base_tree,
"m",
&disp(),
)
.unwrap();
assert_eq!(out, CommitOutcome::Refused(CommitRefusal::EmptyDelta));
assert_eq!(out.token_is("empty-delta"), true);
assert_eq!(
git_run(&coord, &["rev-parse", "refs/heads/dispatch/199"]),
base
);
}
#[test]
fn commit_on_behalf_lost_ref_race_refuses_and_leaves_the_tip() {
let (_tmp, _primary, coord, base, base_tree) = primary_with_coord(199);
let other = git_run(
&coord,
&["commit-tree", &base_tree, "-p", &base, "-m", "other"],
);
git_run(&coord, &["update-ref", "refs/heads/dispatch/199", &other]);
let tree = git::tree_with_file(&coord, &base_tree, "added.txt", "hi\n").unwrap();
let out = commit_on_behalf(
&coord,
"refs/heads/dispatch/199",
&base,
&tree,
"m",
&disp(),
)
.unwrap();
assert_eq!(out, CommitOutcome::Refused(CommitRefusal::LostRefRace));
assert_eq!(
git_run(&coord, &["rev-parse", "refs/heads/dispatch/199"]),
other
);
}
fn commit_idents(dir: &Path, oid: &str) -> (String, String, String, String, String) {
let raw = git_run(dir, &["log", "-1", "--format=%an%n%ae%n%cn%n%ce%n%s", oid]);
let mut it = raw.lines();
(
it.next().unwrap().to_string(),
it.next().unwrap().to_string(),
it.next().unwrap().to_string(),
it.next().unwrap().to_string(),
it.next().unwrap().to_string(),
)
}
#[test]
fn commit_on_behalf_import_mode_preserves_worker_author_and_dispatch_committer() {
let (_tmp, _primary, coord, base, base_tree) = primary_with_coord(199);
let tree = git::tree_with_file(&coord, &base_tree, "added.txt", "hi\n").unwrap();
let prov = Provenance::Import {
author: Identity {
name: "worker-x".to_string(),
email: "worker-x@doctrine".to_string(),
},
committer: Identity {
name: "dispatch".to_string(),
email: "dispatch@doctrine".to_string(),
},
};
let msg = funnel_message(199, "PHASE-02");
let oid =
match commit_on_behalf(&coord, "refs/heads/dispatch/199", &base, &tree, &msg, &prov)
.unwrap()
{
CommitOutcome::Landed { oid } => oid,
other => panic!("expected Landed, got {other:?}"),
};
let (an, ae, cn, ce, subject) = commit_idents(&coord, &oid);
assert_eq!(
(an.as_str(), ae.as_str()),
("worker-x", "worker-x@doctrine")
);
assert_eq!(
(cn.as_str(), ce.as_str()),
("dispatch", "dispatch@doctrine")
);
assert_eq!(subject, msg);
assert!(
subject.starts_with(FUNNEL_MARKER),
"grep-stable marker: {subject}"
);
assert!(subject.contains("SL-199") && subject.contains("PHASE-02"));
}
#[test]
fn commit_on_behalf_conclude_mode_sets_author_equal_committer() {
let (_tmp, _primary, coord, base, base_tree) = primary_with_coord(199);
let tree = git::tree_with_file(&coord, &base_tree, "added.txt", "hi\n").unwrap();
let msg = funnel_message(199, "PHASE-02");
let oid = match commit_on_behalf(
&coord,
"refs/heads/dispatch/199",
&base,
&tree,
&msg,
&disp(),
)
.unwrap()
{
CommitOutcome::Landed { oid } => oid,
other => panic!("expected Landed, got {other:?}"),
};
let (an, ae, cn, ce, _s) = commit_idents(&coord, &oid);
assert_eq!(an, "dispatch");
assert_eq!(ae, "dispatch@doctrine");
assert_eq!((an, ae), (cn, ce), "author == committer == dispatch id");
}
fn dirty_coord(coord: &Path) {
fs::write(coord.join("seed"), "dirtied\n").unwrap(); fs::write(coord.join("staged.txt"), "s\n").unwrap();
git_run(coord, &["add", "staged.txt"]); }
#[test]
fn commit_on_behalf_composes_the_named_tree_not_the_live_index() {
let (_tmp, _primary, coord, base, base_tree) = primary_with_coord(199);
dirty_coord(&coord);
let index_before = git_run(&coord, &["ls-files", "--stage"]);
let tree = git::tree_with_file(&coord, &base_tree, "committed.txt", "c\n").unwrap();
let oid = match commit_on_behalf(
&coord,
"refs/heads/dispatch/199",
&base,
&tree,
"m",
&disp(),
)
.unwrap()
{
CommitOutcome::Landed { oid } => oid,
other => panic!("expected Landed, got {other:?}"),
};
let names = git_run(&coord, &["ls-tree", "-r", "--name-only", &oid]);
let names: Vec<&str> = names.lines().collect();
assert!(
names.contains(&"committed.txt"),
"named tree landed: {names:?}"
);
assert!(
!names.contains(&"staged.txt"),
"index NOT swept in: {names:?}"
);
assert_eq!(
git_run(&coord, &["cat-file", "-p", &format!("{oid}:seed")]),
"base",
"the dirty worktree seed is NOT in the commit"
);
assert_eq!(
git_run(&coord, &["ls-files", "--stage"]),
index_before,
"index untouched"
);
assert_eq!(fs::read_to_string(coord.join("seed")).unwrap(), "dirtied\n");
assert_eq!(fs::read_to_string(coord.join("staged.txt")).unwrap(), "s\n");
}
#[test]
fn commit_on_behalf_fault_leaves_ref_index_and_worktree_byte_unchanged() {
let (_tmp, _primary, coord, base, base_tree) = primary_with_coord(199);
let other = git_run(
&coord,
&["commit-tree", &base_tree, "-p", &base, "-m", "other"],
);
git_run(&coord, &["update-ref", "refs/heads/dispatch/199", &other]);
dirty_coord(&coord);
let ref_before = git_run(&coord, &["rev-parse", "refs/heads/dispatch/199"]);
let status_before = git_run(&coord, &["status", "--porcelain"]);
let tree = git::tree_with_file(&coord, &base_tree, "committed.txt", "c\n").unwrap();
let out = commit_on_behalf(
&coord,
"refs/heads/dispatch/199",
&base,
&tree,
"m",
&disp(),
)
.unwrap();
assert_eq!(out, CommitOutcome::Refused(CommitRefusal::LostRefRace));
assert_eq!(
git_run(&coord, &["rev-parse", "refs/heads/dispatch/199"]),
ref_before
);
assert_eq!(git_run(&coord, &["status", "--porcelain"]), status_before);
}
fn git(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn init_repo(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["config", "user.email", "t@example.com"]);
git(dir, &["config", "user.name", "Test"]);
std::fs::create_dir_all(dir.join(".doctrine")).unwrap();
std::fs::write(dir.join("a.txt"), "hello").unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-q", "-m", "base"]);
}
fn seed_slice_dir(dir: &Path, slice: u32) {
let rel = format!(".doctrine/slice/{slice:03}");
let full = dir.join(&rel);
std::fs::create_dir_all(&full).unwrap();
std::fs::write(
full.join("slice.toml"),
format!("id = {slice}\ntitle = \"test\"\nkind = \"slice\"\nstatus = \"planned\"\n"),
)
.unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", "seed slice dir"]);
}
fn seed_plan(dir: &Path, slice: u32, phases: &str) {
let rel = format!(".doctrine/slice/{slice:03}/plan.toml");
let full = dir.join(&rel);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(&full, phases).unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", "seed plan"]);
}
#[test]
fn arm_spawn_writes_jail_declaration_beside_base() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
run_arm_spawn(
Some(repo.path().to_path_buf()),
Some("68250bcd"),
None,
None,
vec![PathBuf::from("/nix/store")],
true, )
.unwrap();
let spawn = repo.path().join(crate::worktree::ARMING_SUBPATH);
assert!(spawn.join("base").exists(), "base written");
let decl = spawn.join(crate::worktree::ARMING_JAIL_FILE);
assert!(decl.exists(), "jail.toml written beside base (F-4 pairing)");
let policy = JailPolicy::from_toml_str(&std::fs::read_to_string(&decl).unwrap()).unwrap();
assert_eq!(policy.extra_rw, vec![PathBuf::from("/nix/store")]);
assert!(!policy.network, "--no-network declared");
}
#[test]
fn arm_spawn_default_policy_writes_no_declaration_and_clears_stale() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let spawn = repo.path().join(crate::worktree::ARMING_SUBPATH);
let decl = spawn.join(crate::worktree::ARMING_JAIL_FILE);
run_arm_spawn(
Some(repo.path().to_path_buf()),
Some("68250bcd"),
None,
None,
vec![PathBuf::from("/nix/store")],
false,
)
.unwrap();
assert!(decl.exists(), "declaration present after a non-Default arm");
run_arm_spawn(
Some(repo.path().to_path_buf()),
Some("abcd1234"),
None,
None,
vec![],
false,
)
.unwrap();
assert!(spawn.join("base").exists(), "base rewritten");
assert!(
!decl.exists(),
"stale jail.toml cleared on a Default re-arm"
);
}
#[test]
fn arm_spawn_defaults_base_to_head_when_omitted() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let spawn = repo.path().join(crate::worktree::ARMING_SUBPATH);
run_arm_spawn(
Some(repo.path().to_path_buf()),
None,
None,
None,
vec![],
false,
)
.unwrap();
let head = git(repo.path(), &["rev-parse", "HEAD"]);
let written = std::fs::read_to_string(spawn.join("base")).unwrap();
assert_eq!(
written.trim(),
head,
"defaulted base is the coord-root HEAD"
);
run_arm_spawn(
Some(repo.path().to_path_buf()),
Some("abcd1234"),
None,
None,
vec![],
false,
)
.unwrap();
let explicit = std::fs::read_to_string(spawn.join("base")).unwrap();
assert_eq!(
explicit.trim(),
"abcd1234",
"explicit base honored unchanged"
);
}
#[test]
fn dispatch_setup_gates_on_no_plan() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, false);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("no plan"),
"error should mention 'no plan'; got: {err}"
);
}
#[test]
fn dispatch_setup_gates_on_empty_plan() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!("schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n"),
);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, false);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("no phases"),
"error should mention 'no phases'; got: {err}"
);
}
#[test]
fn dispatch_setup_creates_coordination() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!(
"schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n\n[[phase]]\nid = \"PHASE-01\"\nname = \"fixture\"\nobjective = \"fixture\"\n"
),
);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, false);
assert!(result.is_ok(), "setup must succeed; err: {result:?}");
assert!(coord.exists(), "coordination dir exists");
assert!(coord.join("a.txt").exists(), "checkout exists");
assert!(coord.join(".doctrine").exists(), "provisioned");
}
#[test]
fn classify_coord_placement_truth_table() {
assert!(classify_coord_placement(true, true).is_ok());
assert!(classify_coord_placement(true, false).is_ok());
assert!(classify_coord_placement(false, false).is_ok());
assert_eq!(
classify_coord_placement(false, true),
Err("coord-outside-root-under-claude")
);
}
#[test]
fn classify_worktree_role_maps_branch_and_isolation() {
assert_eq!(classify_worktree_role(Some("main"), false), "primary");
assert_eq!(classify_worktree_role(None, false), "primary");
assert_eq!(
classify_worktree_role(Some("dispatch/228"), false),
"primary"
);
assert_eq!(classify_worktree_role(Some("dispatch/228"), true), "coord");
assert_eq!(
classify_worktree_role(Some("dispatch/agent-abc"), true),
"fork"
);
assert_eq!(classify_worktree_role(Some("review/064"), true), "fork");
assert_eq!(classify_worktree_role(None, true), "fork");
}
#[test]
fn dispatch_setup_refuses_outside_root_under_claude() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!(
"schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n\n[[phase]]\nid = \"PHASE-01\"\nname = \"fixture\"\nobjective = \"fixture\"\n"
),
);
let holder = tempfile::tempdir().unwrap();
let coord = holder.path().join("coord");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, true);
assert!(
result.is_err(),
"must refuse outside-root coord under Claude"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("coord-outside-root-under-claude"),
"error names the placement token; got: {err}"
);
assert!(
!coord.exists(),
"no coordination worktree created on refusal"
);
}
#[test]
fn dispatch_setup_allows_inside_root_under_claude() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&format!(
"schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n\n[[phase]]\nid = \"PHASE-01\"\nname = \"fixture\"\nobjective = \"fixture\"\n"
),
);
let coord = src.path().join(".dispatch/SL-085");
let result = run_setup(Some(src.path().to_path_buf()), 85, &coord, true);
assert!(
result.is_ok(),
"inside-root coord must pass; err: {result:?}"
);
assert!(coord.join(".doctrine").exists(), "provisioned inside root");
}
fn seed_phase_tracking(dir: &Path, slice: u32, phase_num: u32, status: &str) {
let state_dir = dir
.join(".doctrine/state/slice")
.join(format!("{slice:03}"))
.join("phases");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(
state_dir.join(format!("phase-{phase_num:02}.toml")),
format!("status = \"{status}\"\n"),
)
.unwrap();
}
fn plan_body(phases: &[(&str, &str)]) -> String {
let mut body =
format!("schema = \"{SCHEMA_PLAN_OVERVIEW}\"\nversion = 1\nslice = \"SL-085\"\n");
for (id, name) in phases {
body.push_str(&format!(
"\n[[phase]]\nid = \"{id}\"\nname = \"{name}\"\nobjective = \"fixture\"\n"
));
}
body
}
#[test]
fn dispatch_plan_next_orders_phases() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "build"),
("PHASE-03", "blocked-one"),
("PHASE-04", "final"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 2, "completed");
seed_phase_tracking(src.path(), 85, 3, "blocked");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_all_blocked() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "blocked-one"),
("PHASE-03", "blocked-two"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 2, "blocked");
seed_phase_tracking(src.path(), 85, 3, "blocked");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_stops_at_blocked_mid() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "first-pending"),
("PHASE-03", "second-pending"),
("PHASE-04", "blocked"),
("PHASE-05", "after-blocked"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 4, "blocked");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_resume_in_progress() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[
("PHASE-01", "setup"),
("PHASE-02", "in-progress"),
("PHASE-03", "next-one"),
("PHASE-04", "next-two"),
]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 2, "in_progress");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "plan-next should succeed; err: {result:?}");
}
#[test]
fn dispatch_plan_next_json() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[("PHASE-01", "setup"), ("PHASE-02", "active")]),
);
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_plan_next(Some(src.path().to_path_buf()), 85, true);
assert!(
result.is_ok(),
"plan-next --json should succeed; err: {result:?}"
);
}
#[test]
fn dispatch_plan_next_no_plan() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
let result = run_plan_next(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_err(), "plan-next without plan should fail");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("not found"),
"error should mention 'not found'; got: {err}"
);
}
fn create_dispatch_ref(dir: &Path, slice: u32) {
let head = git(dir, &["rev-parse", "HEAD"]);
git(
dir,
&[
"update-ref",
&format!("{DISPATCH_REF_PREFIX}{slice:03}"),
&head,
],
);
}
fn create_review_ref(dir: &Path, slice: u32) {
let head = git(dir, &["rev-parse", "HEAD"]);
git(
dir,
&[
"update-ref",
&format!("{REVIEW_REF_PREFIX}{slice:03}"),
&head,
],
);
}
fn advance_trunk(dir: &Path) -> String {
std::fs::write(dir.join("b.txt"), "world").unwrap();
git(dir, &["add", "b.txt"]);
git(dir, &["commit", "-q", "-m", "advance trunk"]);
git(dir, &["rev-parse", "HEAD"])
}
#[test]
fn dispatch_status_fresh_after_setup() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[("PHASE-01", "setup"), ("PHASE-02", "build")]),
);
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_missing_dispatch_ref() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_err(), "status without dispatch ref should fail");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("dispatch branch not found"),
"error should mention 'dispatch branch not found'; got: {err}"
);
}
#[test]
fn dispatch_status_missing_trunk_ref() {
let src = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path()).unwrap();
git(src.path(), &["init", "-q", "-b", "other"]);
git(src.path(), &["config", "user.email", "t@example.com"]);
git(src.path(), &["config", "user.name", "Test"]);
std::fs::create_dir_all(src.path().join(".doctrine")).unwrap();
std::fs::write(src.path().join("a.txt"), "hello").unwrap();
git(src.path(), &["add", "."]);
git(src.path(), &["commit", "-q", "-m", "base"]);
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_err(), "status without trunk ref should fail");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("trunk ref not found"),
"error should mention 'trunk ref not found'; got: {err}"
);
}
#[test]
fn dispatch_status_after_sync() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
create_review_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_moved_trunk() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
advance_trunk(src.path());
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_all_completed_no_review() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_all_completed_review_present() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
create_review_ref(src.path(), 85);
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_coord_removed() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
#[test]
fn dispatch_status_json() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[("PHASE-01", "setup"), ("PHASE-02", "build")]),
);
create_dispatch_ref(src.path(), 85);
let result = run_status(Some(src.path().to_path_buf()), 85, true);
assert!(
result.is_ok(),
"status --json should succeed; err: {result:?}"
);
}
#[test]
fn phase_projection_derives_receipt_status_per_tier() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("phases");
std::fs::create_dir_all(&state_dir).unwrap();
let write = |stem: &str, status: &str| {
std::fs::write(
state_dir.join(format!("{stem}.toml")),
format!("status = \"{status}\"\n"),
)
.unwrap();
};
write("phase-01", "completed"); write("phase-02", "completed"); write("phase-03", "blocked"); std::fs::create_dir_all(state_dir.join("phase-04.toml")).unwrap();
write("phase-05", "in_progress");
let plan = crate::plan::Plan::parse(&plan_body(&[
("PHASE-01", "done-bounded"),
("PHASE-02", "conclude-incomplete"),
("PHASE-03", "blocked"),
("PHASE-04", "malformed"),
("PHASE-05", "in-progress"),
("PHASE-06", "not-started"),
]))
.unwrap();
let boundaries = Boundaries::parse(
"[[boundary]]\nphase = \"PHASE-01\"\ncode_start_oid = \"s\"\ncode_end_oid = \"e\"\n",
)
.unwrap();
let committed: BTreeSet<&str> = boundaries.rows.iter().map(|r| r.phase.as_str()).collect();
let projected = phase_projection(&plan, &state_dir, &committed);
let got: Vec<(&str, ReceiptStatus)> = projected
.iter()
.map(|r| (r.id.as_str(), r.status))
.collect();
assert_eq!(
got,
vec![
("PHASE-01", ReceiptStatus::Completed),
("PHASE-02", ReceiptStatus::ConcludeIncomplete),
("PHASE-03", ReceiptStatus::Blocked),
("PHASE-04", ReceiptStatus::Unknown),
("PHASE-05", ReceiptStatus::InProgress),
("PHASE-06", ReceiptStatus::NotStarted),
]
);
}
mod verify_verb {
use super::super::funnel::{FunnelLanding, land_funnel_transitions, read_funnel_at};
use super::super::{VerifyOutcome, dispatch_ref, funnel_message, verify_phase};
use super::{disp, git_run, primary_with_coord};
use crate::funnel_machine::{Position, Transition, VerifyStatus};
use std::path::{Path, PathBuf};
const SLICE: u32 = 199;
const PHASE: &str = "PHASE-01";
const AT: &str = "2026-07-25T09:00:00Z";
fn config_body(gate: &str) -> String {
format!("[verification]\ngate = {gate}\n\n[dispatch]\nverify-suite = \"gate\"\n")
}
fn coord_at_imported(gate: &str) -> (tempfile::TempDir, PathBuf, String, String) {
let (tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
std::fs::create_dir_all(coord.join(".doctrine")).unwrap();
std::fs::write(coord.join(".doctrine/doctrine.toml"), config_body(gate)).unwrap();
git_run(&coord, &["add", "."]);
git_run(&coord, &["commit", "-q", "-m", "config"]);
let configured = git_run(&coord, &["rev-parse", "HEAD"]);
let ladder = vec![
Transition::Spawn {
fork: "dispatch/worker-a".to_owned(),
base_oid: base.clone(),
},
Transition::RecordWorkerCommit {
fork_tip: "forktip".to_owned(),
},
Transition::Import {
fork_tip: "forktip".to_owned(),
onto: configured.clone(),
},
];
let tip = match land_funnel_transitions(
&coord,
&dispatch_ref(SLICE),
&configured,
SLICE,
PHASE,
&ladder,
None,
None,
&funnel_message(SLICE, PHASE),
AT,
&disp(),
)
.unwrap()
{
FunnelLanding::Landed { oid, .. } => oid,
other => panic!("expected Landed, got {other:?}"),
};
(tmp, coord, configured, tip)
}
fn evidence_at(coord: &Path, oid: &str) -> Option<crate::funnel_machine::VerifyEvidence> {
read_funnel_at(coord, oid, SLICE)
.unwrap()
.row(PHASE)
.and_then(|r| r.verify.clone())
}
fn tip(coord: &Path) -> String {
git_run(coord, &["rev-parse", &dispatch_ref(SLICE)])
}
#[test]
fn a_green_suite_syncs_the_lagging_checkout_and_lands_pass_evidence() {
let (_tmp, coord, _configured, imported) = coord_at_imported("[\"true\"]");
assert!(
!coord
.join(crate::funnel_machine::funnel_record_path(SLICE))
.exists(),
"the record is committed but not yet checked out"
);
let out = verify_phase(&coord, &imported, SLICE, PHASE).unwrap();
let landed = tip(&coord);
assert_eq!(
out,
VerifyOutcome::Verified {
coord_tip: landed.clone(),
suite: "gate".to_owned()
}
);
assert!(
coord
.join(crate::funnel_machine::funnel_record_path(SLICE))
.exists(),
"the forward-sync materialized the reverse set"
);
let stored = evidence_at(&coord, &landed).expect("evidence landed");
assert_eq!(stored.status, VerifyStatus::Pass);
assert_eq!(
stored.verified_oid, imported,
"evidence names the tested tip"
);
assert_eq!(stored.suite, "gate");
let record = read_funnel_at(&coord, &landed, SLICE).unwrap();
assert_eq!(record.position(PHASE), Some(Position::Verified));
assert_eq!(
git_run(
&coord,
&["rev-list", "--count", &format!("{imported}..{landed}")]
),
"1"
);
let changed = super::super::changed_paths_utf8(&coord, &imported, &landed).unwrap();
assert_eq!(
changed,
vec![crate::funnel_machine::funnel_record_path(SLICE)]
);
}
#[test]
fn a_red_suite_lands_fail_evidence_and_keeps_the_position() {
let (_tmp, coord, _configured, imported) = coord_at_imported("[\"false\"]");
let out = verify_phase(&coord, &imported, SLICE, PHASE).unwrap();
match out {
VerifyOutcome::VerifyFailed { suite, detail } => {
assert_eq!(suite, "gate");
assert!(
detail.contains("exited 1"),
"detail names the code: {detail}"
);
}
other => panic!("expected VerifyFailed, got {other:?}"),
}
let landed = tip(&coord);
let stored = evidence_at(&coord, &landed).expect("red evidence IS recorded");
assert_eq!(stored.status, VerifyStatus::Fail);
assert_eq!(
read_funnel_at(&coord, &landed, SLICE)
.unwrap()
.position(PHASE),
Some(Position::Imported),
"a failed verify keeps the position and updates the evidence"
);
}
#[test]
fn a_green_but_mutating_suite_is_recorded_as_a_failure() {
let (_tmp, coord, _configured, imported) =
coord_at_imported("[\"sh\", \"-c\", \"echo mutated >> seed\"]");
match verify_phase(&coord, &imported, SLICE, PHASE).unwrap() {
VerifyOutcome::VerifyFailed { detail, .. } => {
assert!(
detail.contains("suite-mutated-tree"),
"named token: {detail}"
);
assert!(detail.contains("seed"), "detail names the path: {detail}");
}
other => panic!("expected VerifyFailed, got {other:?}"),
}
let stored = evidence_at(&coord, &tip(&coord)).expect("evidence landed");
assert_eq!(
stored.status,
VerifyStatus::Fail,
"a green-but-mutating suite is FAIL evidence, never pass"
);
}
#[test]
fn a_green_untracked_creating_suite_is_recorded_as_a_failure() {
let (_tmp, coord, _configured, imported) =
coord_at_imported("[\"sh\", \"-c\", \"echo x > litter.txt\"]");
match verify_phase(&coord, &imported, SLICE, PHASE).unwrap() {
VerifyOutcome::VerifyFailed { detail, .. } => {
assert!(detail.contains("suite-mutated-tree"), "{detail}");
assert!(detail.contains("litter.txt"), "{detail}");
}
other => panic!("expected VerifyFailed, got {other:?}"),
}
assert_eq!(
evidence_at(&coord, &tip(&coord)).expect("evidence").status,
VerifyStatus::Fail
);
}
#[test]
fn a_suite_that_cannot_run_refuses_and_lands_no_evidence() {
let (_tmp, coord, _configured, imported) =
coord_at_imported("[\"doctrine-no-such-program-xyzzy\"]");
match verify_phase(&coord, &imported, SLICE, PHASE).unwrap() {
VerifyOutcome::Refused { reason, detail } => {
assert_eq!(reason, "verify-suite-unresolved");
assert!(detail.contains("verify-suite"), "names the key: {detail}");
assert!(
detail.contains("doctrine-no-such-program-xyzzy"),
"names the program: {detail}"
);
}
other => panic!("expected Refused, got {other:?}"),
}
assert_eq!(tip(&coord), imported, "nothing landed");
assert!(
evidence_at(&coord, &imported).is_none(),
"no verdict exists, so no evidence may be recorded"
);
}
#[test]
fn an_illegal_verify_refuses_before_any_sync_or_suite() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
std::fs::write(coord.join("scratch.txt"), "operator work\n").unwrap();
match verify_phase(&coord, &base, SLICE, PHASE).unwrap() {
VerifyOutcome::Refused { reason, detail } => {
assert_eq!(reason, "not-spawned");
assert!(detail.contains("expected: spawn"), "{detail}");
}
other => panic!("expected Refused, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(coord.join("scratch.txt")).unwrap(),
"operator work\n",
"the gate refused BEFORE the sync — the tree is untouched"
);
}
#[test]
fn an_operator_edit_in_the_reverse_set_refuses_and_restores_nothing() {
let (_tmp, coord, _configured, imported) = coord_at_imported("[\"true\"]");
let advanced_tree = crate::git::tree_with_file(
&coord,
&format!("{imported}^{{tree}}"),
"seed",
"advanced\n",
)
.unwrap();
let advanced = git_run(
&coord,
&[
"commit-tree",
&advanced_tree,
"-p",
&imported,
"-m",
&funnel_message(SLICE, PHASE),
],
);
git_run(&coord, &["update-ref", &dispatch_ref(SLICE), &advanced]);
std::fs::write(coord.join("seed"), "operator work\n").unwrap();
match verify_phase(&coord, &advanced, SLICE, PHASE).unwrap() {
VerifyOutcome::Refused { reason, detail } => {
assert_eq!(reason, "verify-tree-dirty");
assert!(
detail.contains("seed"),
"names the offending path: {detail}"
);
}
other => panic!("expected Refused, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(coord.join("seed")).unwrap(),
"operator work\n",
"the restore was NOT performed — no discard, ever"
);
assert!(
!coord
.join(crate::funnel_machine::funnel_record_path(SLICE))
.exists(),
"no partial sync either: the refusal is all-or-none"
);
assert_eq!(tip(&coord), advanced, "and nothing was landed");
}
#[test]
fn an_unignored_untracked_file_refuses_the_sync() {
let (_tmp, coord, _configured, imported) = coord_at_imported("[\"true\"]");
std::fs::write(coord.join("notes.txt"), "operator scratch\n").unwrap();
match verify_phase(&coord, &imported, SLICE, PHASE).unwrap() {
VerifyOutcome::Refused { reason, detail } => {
assert_eq!(reason, "verify-tree-dirty");
assert!(detail.contains("notes.txt"), "{detail}");
}
other => panic!("expected Refused, got {other:?}"),
}
assert_eq!(tip(&coord), imported, "nothing landed");
}
#[test]
fn an_already_synced_checkout_needs_no_restore_at_all() {
let (_tmp, coord, _configured, imported) = coord_at_imported("[\"true\"]");
git_run(
&coord,
&[
"restore",
&format!("--source={imported}"),
"--staged",
"--worktree",
"--",
".",
],
);
assert!(
git_run(&coord, &["status", "--porcelain"]).is_empty(),
"precondition: the checkout is in sync with its tip"
);
let out = verify_phase(&coord, &imported, SLICE, PHASE).unwrap();
let landed = tip(&coord);
assert_eq!(
out,
VerifyOutcome::Verified {
coord_tip: landed.clone(),
suite: "gate".to_owned()
}
);
assert_eq!(
evidence_at(&coord, &landed).expect("evidence").verified_oid,
imported
);
}
#[test]
fn re_verifying_at_a_moved_tip_records_fresh_evidence_for_that_tip() {
let (_tmp, coord, _configured, imported) = coord_at_imported("[\"true\"]");
verify_phase(&coord, &imported, SLICE, PHASE).unwrap();
let first = tip(&coord);
verify_phase(&coord, &first, SLICE, PHASE).unwrap();
let second = tip(&coord);
assert_ne!(second, first, "a moved tip is fresh evidence, not a replay");
assert_eq!(
evidence_at(&coord, &second).expect("evidence").verified_oid,
first,
"the evidence re-pins to the tip the suite just ran against"
);
assert_eq!(
git_run(
&coord,
&["rev-list", "--count", &format!("{first}..{second}")]
),
"1"
);
}
}
fn sheet_read(kind: &str) -> anyhow::Result<Option<String>> {
match kind {
"err" => Err(anyhow::anyhow!("sheet unreadable")),
"absent" => Ok(None),
other => Ok(Some(other.to_owned())),
}
}
const ALL_SHEETS: &[&str] = &[
"err",
"absent",
"planned",
"in_progress",
"blocked",
"completed",
"wat",
];
#[test]
fn receipt_status_with_no_position_is_byte_identical_to_the_pre_funnel_derivation() {
for sheet in ALL_SHEETS {
for has_boundary in [false, true] {
let expected = match (*sheet, has_boundary) {
("err" | "wat", _) => ReceiptStatus::Unknown,
("absent" | "planned", _) => ReceiptStatus::NotStarted,
("in_progress", _) => ReceiptStatus::InProgress,
("blocked", _) => ReceiptStatus::Blocked,
("completed", true) => ReceiptStatus::Completed,
("completed", false) => ReceiptStatus::ConcludeIncomplete,
other => panic!("unhandled fixture {other:?}"),
};
assert_eq!(
derive_receipt_status(sheet_read(sheet), has_boundary, None),
expected,
"sheet={sheet} boundary={has_boundary} (position = None)"
);
}
}
}
#[test]
fn receipt_status_with_a_position_follows_the_ordered_matrix() {
use ReceiptStatus as R;
let expected = |position: Position, sheet: &str, boundary: bool| -> R {
if position >= Position::Concluded {
return if boundary {
R::Completed
} else {
R::ConcludeIncomplete
};
}
if boundary {
return R::ConcludeIncomplete; }
match sheet {
"err" | "wat" => R::Unknown, "completed" => R::ConcludeIncomplete, "in_progress" => R::InProgress, "blocked" => R::Blocked, "absent" | "planned" => R::InProgress, other => panic!("unhandled fixture {other:?}"),
}
};
let positions = [
Position::Spawned,
Position::WorkerCommitted,
Position::Imported,
Position::Verified,
Position::Concluded,
Position::Reaped,
];
for position in positions {
for sheet in ALL_SHEETS {
for has_boundary in [false, true] {
assert_eq!(
derive_receipt_status(sheet_read(sheet), has_boundary, Some(position)),
expected(position, sheet, has_boundary),
"position={position:?} sheet={sheet} boundary={has_boundary}"
);
}
}
}
}
#[test]
fn a_lagging_or_unreadable_sheet_never_masks_durable_completion() {
for sheet in ["err", "absent", "in_progress", "planned", "wat"] {
assert_eq!(
derive_receipt_status(sheet_read(sheet), true, Some(Position::Concluded)),
ReceiptStatus::Completed,
"a {sheet} sheet must not mask a concluded + bounded phase"
);
}
}
#[test]
fn run_status_delegates_phase_rows_to_phase_projection() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(
src.path(),
85,
&plan_body(&[("PHASE-01", "setup"), ("PHASE-02", "build")]),
);
let bdir = src.path().join(".doctrine/dispatch/085");
std::fs::create_dir_all(&bdir).unwrap();
std::fs::write(
bdir.join("boundaries.toml"),
"[[boundary]]\nphase = \"PHASE-01\"\ncode_start_oid = \"s\"\ncode_end_oid = \"e\"\n",
)
.unwrap();
git(src.path(), &["add", "-A"]);
git(src.path(), &["commit", "-q", "-m", "seed boundaries"]);
create_dispatch_ref(src.path(), 85);
seed_phase_tracking(src.path(), 85, 1, "completed");
seed_phase_tracking(src.path(), 85, 2, "in_progress");
let result = run_status(Some(src.path().to_path_buf()), 85, false);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
let dispatch_ref = format!("{DISPATCH_REF_PREFIX}085");
let boundaries =
read_ledger::<Boundaries>(src.path(), &dispatch_ref, "085", "boundaries.toml").unwrap();
let committed: BTreeSet<&str> = boundaries.rows.iter().map(|r| r.phase.as_str()).collect();
let plan = crate::slice::read_plan(&src.path().join(".doctrine/slice"), 85).unwrap();
let state_dir = crate::state::phases_dir(src.path(), 85);
let projected = phase_projection(&plan, &state_dir, &committed);
assert_eq!(
projected.iter().map(|r| r.status).collect::<Vec<_>>(),
vec![ReceiptStatus::Completed, ReceiptStatus::InProgress],
"PHASE-01 is boundary-backed Completed; PHASE-02 InProgress",
);
let legacy: Vec<(String, String)> = projected
.iter()
.map(|r| {
(
r.id.clone(),
receipt_status_legacy_str(r.status).to_string(),
)
})
.collect();
assert_eq!(
legacy,
vec![
("PHASE-01".to_string(), "completed".to_string()),
("PHASE-02".to_string(), "in_progress".to_string()),
]
);
}
fn add_dispatch_worktree(repo: &Path, slice: u32, holder: &Path) -> std::path::PathBuf {
let branch = format!("dispatch/{slice:03}");
let head = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["branch", &branch, &head]);
let coord = holder.join("coord");
git(
repo,
&[
"worktree",
"add",
"--quiet",
coord.to_str().unwrap(),
&branch,
],
);
coord
}
fn commit_file(wt: &Path, file: &str, content: &str, msg: &str) -> String {
std::fs::write(wt.join(file), content).unwrap();
git(wt, &["add", file]);
git(wt, &["commit", "-q", "-m", msg]);
git(wt, &["rev-parse", "HEAD"])
}
#[test]
fn trunk_drift_measures_against_trunk() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let fork = git(src.path(), &["rev-parse", "HEAD"]);
let tip = fork.clone();
let d0 = trunk_drift(src.path(), &tip)
.unwrap()
.expect("shared ancestor");
assert_eq!(d0.fork_point, fork, "fork_point is the merge-base");
assert_eq!(d0.ahead, 0, "trunk == fork ⇒ zero ahead");
commit_file(src.path(), "b.txt", "trunk-1\n", "advance trunk 1");
let trunk_tip = commit_file(src.path(), "b.txt", "trunk-2\n", "advance trunk 2");
let d = trunk_drift(src.path(), &tip)
.unwrap()
.expect("shared ancestor");
assert_eq!(d.trunk_tip, trunk_tip, "carries the resolved trunk tip");
assert_eq!(d.fork_point, fork, "fork unchanged — tip did not move");
assert_eq!(d.ahead, 2, "trunk is two commits ahead of the fork");
let d_fresh = trunk_drift(src.path(), &trunk_tip)
.unwrap()
.expect("shared ancestor");
assert_eq!(d_fresh.ahead, 0, "trunk ancestor of tip ⇒ zero ahead");
}
#[test]
fn refresh_base_clean_advances_dispatch() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
let dispatch_tip = commit_file(&coord, "c.txt", "dispatch work\n", "dispatch commit");
let trunk_tip = commit_file(src.path(), "a.txt", "hello trunk-moved\n", "advance trunk");
run_refresh_base(Some(src.path().to_path_buf()), 85).expect("clean refresh");
let new_tip = git(&coord, &["rev-parse", "HEAD"]);
assert_ne!(new_tip, dispatch_tip, "coord HEAD advanced");
let parents = git(&coord, &["rev-list", "--parents", "-n", "1", &new_tip]);
let p: Vec<&str> = parents.split_whitespace().skip(1).collect();
assert_eq!(
p,
vec![dispatch_tip.as_str(), trunk_tip.as_str()],
"merge parents"
);
let mb = git(&coord, &["merge-base", &new_tip, &trunk_tip]);
assert_eq!(mb, trunk_tip, "merge_base(dispatch, trunk) == trunk_tip");
}
#[test]
fn refresh_base_conflict_reports_and_halts() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
let dispatch_tip = commit_file(&coord, "a.txt", "DISPATCH\n", "dispatch edits a.txt");
commit_file(src.path(), "a.txt", "TRUNK\n", "trunk edits a.txt");
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("conflict must Err"));
assert!(
err.contains("a.txt"),
"names the conflicting path; got: {err}"
);
assert!(err.contains("conflicted"), "reports conflict; got: {err}");
let merge_head = coord.join(".git");
let _ = merge_head;
let mh = git(&coord, &["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
assert!(!mh.is_empty(), "MERGE_HEAD left in place");
let tip_now = git(&coord, &["rev-parse", "dispatch/085"]);
assert_eq!(
tip_now, dispatch_tip,
"dispatch ref unadvanced past pre-merge tip"
);
}
#[test]
fn refresh_base_refuses_unrelated_histories() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
git(&coord, &["checkout", "-q", "--orphan", "orphan-tmp"]);
std::fs::write(coord.join("orphan.txt"), "orphan\n").unwrap();
git(&coord, &["add", "orphan.txt"]);
git(&coord, &["commit", "-q", "-m", "orphan root"]);
let orphan = git(&coord, &["rev-parse", "HEAD"]);
git(&coord, &["branch", "-f", "dispatch/085", &orphan]);
git(&coord, &["checkout", "-q", "dispatch/085"]);
git(&coord, &["branch", "-D", "orphan-tmp"]);
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("unrelated histories must Err"));
assert!(
err.contains("unrelated histories"),
"refuses unrelated histories; got: {err}"
);
}
#[test]
fn refresh_base_noop_when_already_fresh() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
let before = commit_file(&coord, "c.txt", "ahead of trunk\n", "dispatch ahead");
run_refresh_base(Some(src.path().to_path_buf()), 85).expect("already-fresh is Ok");
let after = git(&coord, &["rev-parse", "HEAD"]);
assert_eq!(after, before, "no new commit on a fresh dispatch branch");
}
#[test]
fn refresh_base_refuses_dirty_coord() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let holder = tempfile::tempdir().unwrap();
let coord = add_dispatch_worktree(src.path(), 85, holder.path());
advance_trunk(src.path()); std::fs::write(coord.join("a.txt"), "uncommitted edit\n").unwrap();
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("dirty coord must Err"));
assert!(
err.contains("dirty coordination worktree"),
"refuses a dirty coord tree; got: {err}"
);
}
#[test]
fn refresh_base_refuses_without_coord_worktree() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
create_dispatch_ref(src.path(), 85);
let result = run_refresh_base(Some(src.path().to_path_buf()), 85);
let err = format!("{}", result.expect_err("missing coord worktree must Err"));
assert!(
err.contains("no live coordination worktree") && err.contains("setup"),
"hints at setup/resume; got: {err}"
);
}
const LEGACY_CONFLICT_TEXT: &str = "candidate create: 3-way merge of refs/heads/review/085 onto trunk conflicts — pass --worktree to park the candidate branch at the base for manual resolve+commit, or abort (no row/ref/worktree written)";
#[test]
fn candidate_conflict_message_appends_drift_hint() {
let msg = candidate_conflict_message("refs/heads/review/085", "trunk", 3);
assert!(
msg.starts_with(LEGACY_CONFLICT_TEXT),
"legacy text is preserved as a prefix; got: {msg}"
);
assert!(
msg.contains("trunk has advanced 3 commit(s) past this source"),
"names the drift count; got: {msg}"
);
assert!(
msg.contains("refresh-base") && msg.contains("re-prepare + re-create"),
"hints the refresh-base remedy; got: {msg}"
);
assert!(
msg.contains("may be base divergence"),
"non-asserting ('may be'); got: {msg}"
);
}
#[test]
fn candidate_conflict_message_byte_identical_when_not_behind_trunk() {
let msg = candidate_conflict_message("refs/heads/review/085", "trunk", 0);
assert_eq!(msg, LEGACY_CONFLICT_TEXT, "ahead==0 ⇒ verbatim legacy text");
}
fn all_done_inputs() -> GuidanceInputs<fn() -> Vec<String>> {
GuidanceInputs {
all_completed: true,
bundle_stale: false,
review_exists: false,
coord_live: true,
admitted: false,
admitted_is_ancestor: false,
next_phases: Vec::new,
}
}
#[test]
fn select_guidance_refresh_base_precedes_prepare_review_and_audit() {
let g = select_guidance(GuidanceInputs {
bundle_stale: true,
..all_done_inputs()
});
assert!(
matches!(g, NextGuidance::RefreshBase),
"stale ⇒ RefreshBase"
);
assert_eq!(g.to_json().kind, "refresh_base");
let g2 = select_guidance(GuidanceInputs {
bundle_stale: true,
review_exists: true,
admitted: true,
..all_done_inputs()
});
assert!(
matches!(g2, NextGuidance::RefreshBase),
"stale wins over the audit legs"
);
}
#[test]
fn select_guidance_fresh_bundle_keeps_existing_guidance() {
let no_review = select_guidance(all_done_inputs());
assert!(
matches!(no_review, NextGuidance::PrepareReview),
"fresh + no review ⇒ PrepareReview (unchanged)"
);
let with_review = select_guidance(GuidanceInputs {
review_exists: true,
..all_done_inputs()
});
assert!(
matches!(with_review, NextGuidance::AuditOrCandidateStatus),
"fresh + review ⇒ audit leg (unchanged)"
);
}
#[test]
fn dispatch_status_stale_bundle_routes_refresh_base() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
seed_slice_dir(src.path(), 85);
seed_plan(src.path(), 85, &plan_body(&[("PHASE-01", "setup")]));
create_dispatch_ref(src.path(), 85);
advance_trunk(src.path());
seed_phase_tracking(src.path(), 85, 1, "completed");
let result = run_status(Some(src.path().to_path_buf()), 85, true);
assert!(result.is_ok(), "status should succeed; err: {result:?}");
}
fn reg_row(phase: &str, provenance: BoundaryProvenance) -> BoundaryRow {
BoundaryRow {
phase: phase.to_string(),
code_start_oid: "s".to_string(),
code_end_oid: "e".to_string(),
provenance,
}
}
fn committed_set<'a>(phases: &'a [&str]) -> BTreeSet<&'a str> {
phases.iter().copied().collect()
}
#[test]
fn guard_total_loss_names_every_funnel_phase() {
let registry = vec![
reg_row("PHASE-01", BoundaryProvenance::Funnel),
reg_row("PHASE-02", BoundaryProvenance::Funnel),
];
let committed = committed_set(&[]);
let missing = missing_committed_funnel_phases(®istry, &committed);
assert_eq!(missing, vec!["PHASE-01", "PHASE-02"]);
}
#[test]
fn guard_partial_loss_names_only_the_uncommitted_phase() {
let registry = vec![
reg_row("PHASE-01", BoundaryProvenance::Funnel),
reg_row("PHASE-02", BoundaryProvenance::Funnel),
];
assert_eq!(
missing_committed_funnel_phases(®istry, &committed_set(&["PHASE-01"])),
vec!["PHASE-02"],
);
assert!(
missing_committed_funnel_phases(®istry, &committed_set(&["PHASE-01", "PHASE-02"]))
.is_empty(),
"a complete committed ledger leaves nothing missing",
);
}
#[test]
fn guard_includes_unknown_excludes_solo_and_manual() {
let registry = vec![
reg_row("PHASE-01", BoundaryProvenance::Unknown),
reg_row("PHASE-02", BoundaryProvenance::Solo),
reg_row("PHASE-03", BoundaryProvenance::Manual),
];
let missing = missing_committed_funnel_phases(®istry, &committed_set(&[]));
assert_eq!(
missing,
vec!["PHASE-01"],
"Unknown halts; Solo/Manual excluded"
);
}
#[test]
fn guard_empty_registry_is_silent() {
assert!(missing_committed_funnel_phases(&[], &committed_set(&["PHASE-01"])).is_empty(),);
}
fn cand_row(
target_ref: &str,
source_ref: &str,
role: CandidateRole,
kind: CandidateKind,
status: CandidateStatus,
) -> CandidateRow {
CandidateRow {
id: format!("cand-{target_ref}"),
label: "l".into(),
kind,
role,
payload: CandidatePayload::ImplBundle,
target_ref: target_ref.into(),
source_ref: source_ref.into(),
source_oid: "0".repeat(40),
base_ref: "refs/heads/main".into(),
base_oid: "0".repeat(40),
merge_oid: "0".repeat(40),
status,
supersedes: String::new(),
reason: String::new(),
created_by: "test".into(),
created_at: "2026-01-01".into(),
ingested_at: String::new(),
merge_provenance: crate::ledger::MergeProvenance::Doctrine,
}
}
fn jrow(target_ref: &str, status: LedgerStatus) -> JournalRow {
JournalRow {
source_oid: "0".repeat(40),
target_ref: target_ref.into(),
expected_old_oid: "0".repeat(40),
planned_new_oid: "0".repeat(40),
applied_new_oid: String::new(),
status,
}
}
fn audit_surface_row() -> CandidateRow {
cand_row(
"refs/heads/candidate/200/review-001",
"refs/heads/review/200",
CandidateRole::ReviewSurface,
CandidateKind::Audit,
CandidateStatus::Created,
)
}
fn verified_review_journal() -> Journal {
Journal {
rows: vec![jrow("refs/heads/review/200", LedgerStatus::Verified)],
..Default::default()
}
}
fn trace_err(
candidates: &Candidates,
journal: &Journal,
ref_name: &str,
budget: u32,
) -> String {
let err = trace_candidate_provenance(candidates, journal, "200", ref_name, budget)
.expect_err("trace should refuse");
format!("{err}")
}
#[test]
fn resolve_trunk_payload_candidate_without_admission_refuses() {
let candidates = Candidates {
rows: vec![audit_surface_row()],
..Default::default()
};
let err = resolve_trunk_payload(
std::path::Path::new("/nonexistent"),
"200",
&Journal::default(),
&candidates,
)
.expect_err("candidate-active without a close_target admission must refuse");
assert!(
format!("{err}").contains("close_target"),
"refusal names the missing close_target admission: {err}"
);
}
#[test]
fn trace_accepts_audit_surface_to_verified_root() {
let candidates = Candidates {
rows: vec![audit_surface_row()],
..Default::default()
};
let row = trace_candidate_provenance(
&candidates,
&verified_review_journal(),
"200",
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
)
.expect("clean audit surface should trace to the verified root");
assert_eq!(row.target_ref, "refs/heads/candidate/200/review-001");
}
#[test]
fn trace_over_budget_refuses() {
let candidates = Candidates {
rows: vec![audit_surface_row()],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
0,
);
assert!(err.contains("too deep or cyclic"), "got: {err}");
}
#[test]
fn trace_cyclic_chain_refuses() {
let a = cand_row(
"refs/heads/candidate/200/a",
"refs/heads/candidate/200/b",
CandidateRole::CloseTarget,
CandidateKind::Audit,
CandidateStatus::Created,
);
let b = cand_row(
"refs/heads/candidate/200/b",
"refs/heads/candidate/200/a",
CandidateRole::CloseTarget,
CandidateKind::Audit,
CandidateStatus::Created,
);
let candidates = Candidates {
rows: vec![a, b],
..Default::default()
};
let err = trace_err(
&candidates,
&Journal::default(),
"refs/heads/candidate/200/a",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("too deep or cyclic"), "got: {err}");
}
#[test]
fn trace_ambiguous_row_refuses() {
let candidates = Candidates {
rows: vec![audit_surface_row(), audit_surface_row()],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("ambiguous candidate row"), "got: {err}");
}
#[test]
fn trace_missing_row_refuses() {
let candidates = Candidates::default();
let err = trace_err(
&candidates,
&Journal::default(),
"refs/heads/candidate/200/ghost",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("no recorded candidate row"), "got: {err}");
}
#[test]
fn trace_conflicted_status_refuses() {
let mut row = audit_surface_row();
row.status = CandidateStatus::Conflicted;
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("not clean"), "got: {err}");
}
#[test]
fn trace_experiment_kind_refuses() {
let mut row = audit_surface_row();
row.kind = CandidateKind::Experiment;
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(
err.contains("kind=Experiment") || err.contains("only an audit review_surface"),
"got: {err}"
);
}
#[test]
fn trace_scratch_role_refuses() {
let mut row = audit_surface_row();
row.role = CandidateRole::Scratch;
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&verified_review_journal(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(
err.contains("role=Scratch") || err.contains("only an audit review_surface"),
"got: {err}"
);
}
#[test]
fn trace_non_evidence_hop_refuses() {
let row = cand_row(
"refs/heads/candidate/200/review-001",
"refs/heads/feature/random",
CandidateRole::ReviewSurface,
CandidateKind::Audit,
CandidateStatus::Created,
);
let candidates = Candidates {
rows: vec![row],
..Default::default()
};
let err = trace_err(
&candidates,
&Journal::default(),
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("non-evidence"), "got: {err}");
}
#[test]
fn trace_unverified_journal_root_refuses() {
let candidates = Candidates {
rows: vec![audit_surface_row()],
..Default::default()
};
let journal = Journal {
rows: vec![jrow("refs/heads/review/200", LedgerStatus::Pending)],
..Default::default()
};
let err = trace_err(
&candidates,
&journal,
"refs/heads/candidate/200/review-001",
CANDIDATE_PROVENANCE_DEPTH_BUDGET,
);
assert!(err.contains("not verified"), "got: {err}");
}
#[test]
fn ref_classifiers_agree() {
assert!(is_journaled_evidence_ref("refs/heads/review/200", "200"));
assert!(is_journaled_evidence_ref("refs/heads/phase/200-03", "200"));
assert!(!is_journaled_evidence_ref("refs/heads/phase/200-xx", "200"));
assert!(!is_journaled_evidence_ref(
"refs/heads/candidate/200/x",
"200"
));
assert!(is_candidate_ref("refs/heads/candidate/200/review-001"));
assert!(!is_candidate_ref("refs/heads/review/200"));
}
fn posture_cfg(authoring: Option<&str>) -> crate::dispatch_config::DispatchConfig {
crate::dispatch_config::DispatchConfig {
authoring_branch: authoring.map(str::to_owned),
..Default::default()
}
}
#[test]
fn integrate_refused_when_head_on_buffer() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path()); let cfg = posture_cfg(Some("refs/heads/edge"));
let err = guard_not_on_integration_ref(src.path(), &cfg).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(corpus_guard::REFUSE_ON_TRUNK),
"names the g1 token: {msg}"
);
assert!(
msg.contains("refs/heads/main"),
"names the buffer ref: {msg}"
);
assert!(
msg.contains("git fetch . refs/heads/edge:main")
&& msg.contains("never `checkout main`"),
"names the fetch-not-checkout recovery: {msg}"
);
}
#[test]
fn integrate_allowed_on_authoring_branch() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
git(src.path(), &["checkout", "-q", "-b", "edge"]);
let cfg = posture_cfg(Some("refs/heads/edge"));
assert!(guard_not_on_integration_ref(src.path(), &cfg).is_ok());
}
#[test]
fn g1_inert_when_posture_unset() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let cfg = posture_cfg(None);
assert!(guard_not_on_integration_ref(src.path(), &cfg).is_ok());
}
#[test]
fn g1_guards_only_the_integrate_verb_entry() {
let this = include_str!("dispatch.rs");
let prod = this
.split("#[cfg(test)]")
.next()
.expect("production source before the test module");
let call_sites = prod
.lines()
.filter(|l| {
l.contains("guard_not_on_integration_ref(")
&& !l.contains("fn guard_not_on_integration_ref")
&& !l.trim_start().starts_with("//")
&& !l.trim_start().starts_with("///")
})
.count();
assert_eq!(
call_sites, 1,
"exactly one g1 call site (the integrate verb)"
);
assert!(
prod.contains(
"let cfg = crate::dtoml::load_doctrine_toml(&root)?.dispatch;\n guard_not_on_integration_ref(&root, &cfg)?;"
),
"the g1 call site is run_integrate's verb entry"
);
for fn_name in ["fn candidate_create", "fn run_candidate_admit"] {
let body_start = prod.find(fn_name).expect("candidate fn present");
let after = &prod[body_start..];
let window = &after[..after.len().min(6000)];
assert!(
!window.contains("guard_not_on_integration_ref("),
"{fn_name} must not call g1 (advances no integration ref)"
);
}
}
#[test]
fn recorded_row_is_terminal_verified_all_oids_payload() {
let payload = "a".repeat(40);
let row = recorded_row("refs/heads/main", payload.clone());
assert_eq!(row.target_ref, "refs/heads/main");
assert_eq!(row.source_oid, payload, "source == payload");
assert_eq!(
row.expected_old_oid, payload,
"expected_old == payload (= planned, NOT tip)"
);
assert_eq!(row.planned_new_oid, payload, "planned == payload");
assert_eq!(
row.applied_new_oid, payload,
"applied == payload (terminal, not empty)"
);
assert_eq!(row.status, LedgerStatus::Verified, "statement of fact");
}
#[test]
fn plan_recorded_trunk_row_returns_earned_row_when_payload_on_trunk() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path()); let payload = commit_file(src.path(), "p.txt", "phase work\n", "phase 01 code");
git(src.path(), &["branch", "phase/211-01", &payload]);
commit_file(
src.path(),
"t.txt",
"trunk ahead\n",
"trunk advances past payload",
);
let journal = Journal {
rows: vec![jrow("refs/heads/phase/211-01", LedgerStatus::Verified)],
..Default::default()
};
let candidates = Candidates::default();
let row =
plan_recorded_trunk_row(src.path(), "211", &journal, &candidates, "refs/heads/main")
.expect("payload on trunk ⇒ earned row");
assert_eq!(
row.planned_new_oid, payload,
"the earned row records the payload"
);
assert_eq!(row.status, LedgerStatus::Verified);
}
#[test]
fn plan_recorded_trunk_row_refuses_when_payload_not_on_trunk() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path()); git(src.path(), &["checkout", "-q", "-b", "phase/211-01"]);
let payload = commit_file(src.path(), "p.txt", "unlanded phase\n", "phase 01 code");
git(src.path(), &["checkout", "-q", "main"]);
commit_file(
src.path(),
"t.txt",
"trunk only\n",
"trunk advances without the phase",
);
let journal = Journal {
rows: vec![jrow("refs/heads/phase/211-01", LedgerStatus::Verified)],
..Default::default()
};
let candidates = Candidates::default();
let err =
plan_recorded_trunk_row(src.path(), "211", &journal, &candidates, "refs/heads/main")
.expect_err("unlanded payload must refuse");
let msg = format!("{err}");
assert!(
msg.contains("is not an ancestor"),
"R1 refusal names the earned-check failure; got: {msg}"
);
assert!(
msg.contains(&payload),
"refusal names the payload oid; got: {msg}"
);
}
fn seed_dispatch_journal(dir: &Path, slice: u32, journal: &Journal) -> String {
let rel = format!(".doctrine/dispatch/{slice:03}/journal.toml");
let full = dir.join(&rel);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(&full, journal.to_toml().unwrap()).unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", "seed journal"]);
let head = git(dir, &["rev-parse", "HEAD"]);
git(
dir,
&[
"update-ref",
&format!("{DISPATCH_REF_PREFIX}{slice:03}"),
&head,
],
);
head
}
fn read_dispatch_journal(dir: &Path, slice: u32) -> Journal {
let coord = format!("{DISPATCH_REF_PREFIX}{slice:03}");
read_ledger::<Journal>(dir, &coord, &format!("{slice:03}"), "journal.toml").unwrap()
}
#[test]
fn run_record_integration_refuses_trunk_deliver_to_mismatch() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let err = run_record_integration(
Some(src.path().to_path_buf()),
211,
Some("refs/heads/other"),
)
.expect_err("mismatched --trunk must refuse");
let msg = format!("{err}");
assert!(msg.contains("does not match"), "names the mismatch: {msg}");
assert!(
msg.contains("refs/heads/other"),
"names the given ref: {msg}"
);
assert!(msg.contains("refs/heads/main"), "names deliver_to: {msg}");
}
#[test]
fn run_record_integration_verified_row_is_idempotent_noop() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let journal = Journal {
rows: vec![jrow("refs/heads/main", LedgerStatus::Verified)],
..Default::default()
};
let tip_before = seed_dispatch_journal(src.path(), 211, &journal);
run_record_integration(Some(src.path().to_path_buf()), 211, Some("refs/heads/main"))
.expect("a Verified row ⇒ no-op Ok");
let tip_after = git(
src.path(),
&["rev-parse", &format!("{DISPATCH_REF_PREFIX}211")],
);
assert_eq!(
tip_before, tip_after,
"no-op leaves dispatch/<slice> unadvanced"
);
}
#[test]
fn run_record_integration_replaces_failed_trunk_row() {
let src = tempfile::tempdir().unwrap();
init_repo(src.path());
let payload = commit_file(src.path(), "p.txt", "phase work\n", "phase 01 code");
git(src.path(), &["branch", "phase/211-01", &payload]);
commit_file(
src.path(),
"t.txt",
"trunk ahead\n",
"advance main past payload",
);
let journal = Journal {
rows: vec![
jrow("refs/heads/phase/211-01", LedgerStatus::Verified),
jrow("refs/heads/main", LedgerStatus::Failed),
],
..Default::default()
};
seed_dispatch_journal(src.path(), 211, &journal);
run_record_integration(Some(src.path().to_path_buf()), 211, Some("refs/heads/main"))
.expect("a Failed row is replaced with the earned Verified row");
let reloaded = read_dispatch_journal(src.path(), 211);
let main_rows: Vec<_> = reloaded
.rows
.iter()
.filter(|r| r.target_ref == "refs/heads/main")
.collect();
assert_eq!(main_rows.len(), 1, "replaced in place, not duplicated");
assert_eq!(
main_rows[0].status,
LedgerStatus::Verified,
"the earned row is Verified"
);
assert_eq!(
main_rows[0].planned_new_oid, payload,
"the earned row records the payload"
);
}
fn pset(paths: &[&[u8]]) -> BTreeSet<Vec<u8>> {
paths.iter().map(|p| p.to_vec()).collect()
}
#[test]
fn ingest_validate_parent_gate() {
let base = "aaa".to_owned();
let source = "bbb".to_owned();
let d = pset(&[]); let c = pset(&[b"x"]);
let ordered = vec![base.clone(), source.clone()];
assert!(validate_ingest_provenance(&ordered, &base, &source, &d, &c, &[]).is_ok());
let reversed = vec![source.clone(), base.clone()];
assert!(validate_ingest_provenance(&reversed, &base, &source, &d, &c, &[]).is_err());
let single = vec![base.clone()];
assert!(validate_ingest_provenance(&single, &base, &source, &d, &c, &[]).is_err());
let three = vec![base.clone(), source.clone(), "ccc".to_owned()];
assert!(validate_ingest_provenance(&three, &base, &source, &d, &c, &[]).is_err());
}
#[test]
fn ingest_request_carries_fields() {
let req = IngestRequest {
slice: 212,
label: "cand-x".to_owned(),
ingested_at: "2026-07-22T00:00:00Z".to_owned(),
};
assert_eq!(req.slice, 212);
assert_eq!(req.label, "cand-x");
assert!(!req.ingested_at.is_empty());
}
#[test]
fn ingest_validate_subset_gate_names_offending_path() {
let base = "aaa".to_owned();
let source = "bbb".to_owned();
let parents = vec![base.clone(), source.clone()];
let conflict_paths = pset(&[b"conflict.rs"]);
let diff_from_mechanical = pset(&[b"conflict.rs", b"arbitrary.rs"]);
let rej = validate_ingest_provenance(
&parents,
&base,
&source,
&diff_from_mechanical,
&conflict_paths,
&[],
)
.unwrap_err();
assert!(
rej.reason.contains("arbitrary.rs"),
"reason names the offending path: {}",
rej.reason
);
let ok = pset(&[b"conflict.rs"]);
assert!(
validate_ingest_provenance(&parents, &base, &source, &ok, &conflict_paths, &[]).is_ok()
);
}
#[test]
fn ingest_validate_subset_gate_is_byte_wise() {
let base = "aaa".to_owned();
let source = "bbb".to_owned();
let parents = vec![base.clone(), source.clone()];
let conflict_paths = pset(&[b"\xff\xfe.bin"]);
let diff_from_mechanical = pset(&[b"\xff\xfe.bin", b"\xff\x01arb"]);
let rej = validate_ingest_provenance(
&parents,
&base,
&source,
&diff_from_mechanical,
&conflict_paths,
&[],
)
.unwrap_err();
assert!(!rej.reason.is_empty());
}
#[test]
fn ingest_validate_marker_gate_advisory() {
let base = "aaa".to_owned();
let source = "bbb".to_owned();
let parents = vec![base.clone(), source.clone()];
let conflict_paths = pset(&[b"conflict.rs"]);
let diff_from_mechanical = pset(&[b"conflict.rs"]);
let marker_paths = vec![b"conflict.rs".to_vec()];
let rej = validate_ingest_provenance(
&parents,
&base,
&source,
&diff_from_mechanical,
&conflict_paths,
&marker_paths,
)
.unwrap_err();
assert!(!rej.reason.is_empty());
assert!(
validate_ingest_provenance(
&parents,
&base,
&source,
&diff_from_mechanical,
&conflict_paths,
&[]
)
.is_ok()
);
}
fn guidance_row(label: &str, status: CandidateStatus, merge_oid: &str) -> CandidateRow {
CandidateRow {
id: format!("cand-212-{label}"),
label: label.to_owned(),
kind: CandidateKind::Audit,
role: CandidateRole::CloseTarget,
payload: CandidatePayload::Code,
target_ref: format!("refs/heads/candidate/212/{label}"),
source_ref: "refs/heads/source".to_owned(),
source_oid: "src".to_owned(),
base_ref: "refs/heads/main".to_owned(),
base_oid: "base".to_owned(),
merge_oid: merge_oid.to_owned(),
status,
supersedes: String::new(),
reason: String::new(),
created_by: "test".to_owned(),
created_at: "2026-01-01".to_owned(),
ingested_at: String::new(),
merge_provenance: crate::ledger::MergeProvenance::Doctrine,
}
}
#[test]
fn candidate_status_prescribes_ingest_for_conflicted_row() {
let mut ledger = Candidates::default();
ledger.rows.push(guidance_row(
"review-001",
CandidateStatus::Created,
"mergeoid",
));
ledger
.rows
.push(guidance_row("close-002", CandidateStatus::Conflicted, ""));
let lines = next_command_lines("212", &ledger, false);
let ingest: Vec<&String> = lines
.iter()
.filter(|l| l.contains("candidate ingest"))
.collect();
assert_eq!(
ingest.len(),
1,
"one ingest line for the one conflicted row"
);
assert!(
ingest[0].contains("--label close-002"),
"prescription names the conflicted row's label: {}",
ingest[0]
);
assert!(
ingest[0].contains("--slice 212"),
"prescription carries the slice: {}",
ingest[0]
);
}
#[test]
fn candidate_status_omits_ingest_when_no_conflict() {
let mut ledger = Candidates::default();
ledger.rows.push(guidance_row(
"review-001",
CandidateStatus::Created,
"mergeoid",
));
let lines = next_command_lines("212", &ledger, false);
assert!(
!lines.iter().any(|l| l.contains("candidate ingest")),
"no ingest line without a conflicted row"
);
}
fn init_test_repo(root: &Path) {
git(root, &["init", "-q", "-b", "main"]);
git(root, &["config", "user.email", "t@doctrine.invalid"]);
git(root, &["config", "user.name", "Doctrine Test"]);
fs::write(root.join(".gitignore"), ".doctrine/\n").unwrap();
git(root, &["add", ".gitignore"]);
git(root, &["commit", "-q", "-m", "root"]);
}
fn seed_conflicted_row(
root: &Path,
slice: u32,
label: &str,
base_oid: &str,
source_oid: &str,
) -> String {
let slice3 = format!("{slice:03}");
let target_ref = format!("refs/heads/candidate/{slice3}/{label}");
git(
root,
&["branch", &format!("candidate/{slice3}/{label}"), base_oid],
);
let mut ledger = Candidates::default();
ledger.rows.push(CandidateRow {
id: format!("cand-{slice3}-{label}"),
label: label.to_owned(),
kind: CandidateKind::Audit,
role: CandidateRole::CloseTarget,
payload: CandidatePayload::Code,
target_ref: target_ref.clone(),
source_ref: "refs/heads/source".to_owned(),
source_oid: source_oid.to_owned(),
base_ref: "refs/heads/main".to_owned(),
base_oid: base_oid.to_owned(),
merge_oid: String::new(),
status: CandidateStatus::Conflicted,
supersedes: String::new(),
reason: String::new(),
created_by: "test".to_owned(),
created_at: "2026-01-01".to_owned(),
ingested_at: String::new(),
merge_provenance: crate::ledger::MergeProvenance::Doctrine,
});
crate::ledger::write_candidates(root, slice, &ledger).unwrap();
target_ref
}
fn seed_conflicted_candidate(root: &Path, slice: u32, label: &str) -> (String, String, String) {
init_test_repo(root);
fs::write(root.join("trunk.txt"), "COMMON\n").unwrap();
git(root, &["add", "trunk.txt"]);
git(root, &["commit", "-q", "-m", "common trunk"]);
git(root, &["checkout", "-q", "-b", "source"]);
fs::write(root.join("trunk.txt"), "SOURCE\n").unwrap();
git(root, &["add", "trunk.txt"]);
git(root, &["commit", "-q", "-m", "source edit"]);
let source_oid = git(root, &["rev-parse", "HEAD"]);
git(root, &["checkout", "-q", "main"]);
fs::write(root.join("trunk.txt"), "MAIN\n").unwrap();
git(root, &["add", "trunk.txt"]);
git(root, &["commit", "-q", "-m", "base edit"]);
let base_oid = git(root, &["rev-parse", "HEAD"]);
let target_ref = seed_conflicted_row(root, slice, label, &base_oid, &source_oid);
(base_oid, source_oid, target_ref)
}
fn commit_operator_merge(
root: &Path,
base_oid: &str,
source_oid: &str,
target_ref: &str,
) -> String {
let tree = git(root, &["write-tree"]);
let r = git(
root,
&[
"commit-tree",
&tree,
"-p",
base_oid,
"-p",
source_oid,
"-m",
"operator merge",
],
);
git(root, &["update-ref", target_ref, &r]);
git(root, &["reset", "-q", "--hard", "HEAD"]);
r
}
fn operator_resolve(
root: &Path,
base_oid: &str,
source_oid: &str,
target_ref: &str,
resolved: &str,
) -> String {
git(root, &["checkout", "-q", "main"]);
fs::write(root.join("trunk.txt"), resolved).unwrap();
git(root, &["add", "trunk.txt"]);
commit_operator_merge(root, base_oid, source_oid, target_ref)
}
#[test]
fn run_candidate_ingest_fills_row_with_operator_provenance_and_admits() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let (base, source, target_ref) = seed_conflicted_candidate(root, 212, "review-001");
let r = operator_resolve(root, &base, &source, &target_ref, "RESOLVED\n");
let req = IngestRequest {
slice: 212,
label: "review-001".to_owned(),
ingested_at: "2026-02-02".to_owned(),
};
candidate_ingest(root, &req).expect("ingest accepts a faithful operator merge");
let ledger = read_candidates(root, 212).unwrap();
let row = ledger
.rows
.iter()
.find(|r| r.label == "review-001")
.expect("row present");
assert_eq!(row.status, CandidateStatus::Created, "row flips to Created");
assert_eq!(row.merge_oid, r, "merge_oid pinned to the resolved R");
assert_eq!(
row.merge_provenance,
crate::ledger::MergeProvenance::OperatorIngest,
"provenance records the operator ingest"
);
assert!(!row.ingested_at.is_empty(), "ingested_at stamped");
let admit_req = AdmitRequest {
slice: 212,
role: CandidateRole::CloseTarget,
candidate: target_ref.clone(),
review: None,
admitted_at: "2026-02-03".to_owned(),
};
candidate_admit(root, &admit_req).expect("admit pins the operator-ingested candidate");
let admitted = read_candidates(root, 212).unwrap();
assert_eq!(
admitted
.current_admission
.close_target
.as_ref()
.expect("close-target admission")
.admitted_oid,
r,
"admit pins the resolved merge R"
);
}
fn operator_merge(
root: &Path,
target_ref: &str,
parents: &[&str],
writes: &[(&str, &str)],
) -> String {
git(root, &["checkout", "-q", "main"]);
for (path, content) in writes {
fs::write(root.join(path), content).unwrap();
git(root, &["add", path]);
}
let tree = git(root, &["write-tree"]);
let mut args = vec!["commit-tree", &tree];
for p in parents {
args.push("-p");
args.push(p);
}
args.push("-m");
args.push("operator merge");
let r = git(root, &args);
git(root, &["update-ref", target_ref, &r]);
git(root, &["reset", "-q", "--hard", "HEAD"]);
r
}
#[test]
fn ingest_governance_arbitrary_and_reversed_reject_genuine_accepts() {
let req = |slice| IngestRequest {
slice,
label: "review-001".to_owned(),
ingested_at: "2026-02-02".to_owned(),
};
{
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let (base, source, target_ref) = seed_conflicted_candidate(root, 212, "review-001");
operator_merge(
root,
&target_ref,
&[&base, &source],
&[("trunk.txt", "RESOLVED\n"), ("arbitrary.txt", "sneaked\n")],
);
let err = candidate_ingest(root, &req(212))
.expect_err("an arbitrary-tree edit is rejected")
.to_string();
assert!(
err.contains("arbitrary.txt"),
"the refusal names the arbitrary path: {err}"
);
}
{
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let (base, source, target_ref) = seed_conflicted_candidate(root, 212, "review-001");
operator_merge(
root,
&target_ref,
&[&source, &base],
&[("trunk.txt", "RESOLVED\n")],
);
assert!(
candidate_ingest(root, &req(212)).is_err(),
"reversed parents [source, base] are rejected"
);
}
{
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let (base, source, target_ref) = seed_conflicted_candidate(root, 212, "review-001");
let r = operator_resolve(root, &base, &source, &target_ref, "RESOLVED\n");
candidate_ingest(root, &req(212)).expect("the genuine 3-way is accepted");
let ledger = read_candidates(root, 212).unwrap();
let row = ledger
.rows
.iter()
.find(|r| r.label == "review-001")
.unwrap();
assert_eq!(row.status, CandidateStatus::Created);
assert_eq!(row.merge_oid, r);
assert_eq!(
row.merge_provenance,
crate::ledger::MergeProvenance::OperatorIngest
);
}
}
fn taxonomy_req() -> IngestRequest {
IngestRequest {
slice: 212,
label: "review-001".to_owned(),
ingested_at: "2026-02-02".to_owned(),
}
}
#[test]
fn ingest_taxonomy_non_utf8_path_round_trips_byte_safe() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let name: &[u8] = b"payload\xff.txt"; let path = |root: &Path| root.join(OsStr::from_bytes(name));
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_test_repo(root);
fs::write(path(root), "COMMON\n").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "common"]);
git(root, &["checkout", "-q", "-b", "source"]);
fs::write(path(root), "SOURCE\n").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "source"]);
let source = git(root, &["rev-parse", "HEAD"]);
git(root, &["checkout", "-q", "main"]);
fs::write(path(root), "MAIN\n").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "base"]);
let base = git(root, &["rev-parse", "HEAD"]);
let target_ref = seed_conflicted_row(root, 212, "review-001", &base, &source);
let mb = git::merge_base(root, &base, &source).unwrap().unwrap();
let (tc, stages) = match git::merge_tree(root, &mb, &base, &source).unwrap() {
git::MergeTree::Conflict { tree, stages } => (tree, stages),
git::MergeTree::Clean { .. } => panic!("a non-UTF-8 modify/modify must conflict"),
};
let c: BTreeSet<Vec<u8>> = stages.iter().map(|s| s.path.clone()).collect();
assert!(
c.contains(&name.to_vec()),
"C carries the exact non-UTF-8 path bytes (no lossy compare)"
);
git(root, &["checkout", "-q", "main"]);
fs::write(path(root), "RESOLVED\n").unwrap();
git(root, &["add", "-A"]);
let r = commit_operator_merge(root, &base, &source, &target_ref);
let r_tree = tree_of(root, &r).unwrap();
let d: BTreeSet<Vec<u8>> = git::changed_paths(root, &r_tree, &tc).unwrap();
assert!(
!d.is_empty() && d.iter().all(|p| c.contains(p)),
"D ⊆ C over raw bytes (non-empty, no lossy compare): D={d:?} C={c:?}"
);
candidate_ingest(root, &taxonomy_req())
.expect("the non-UTF-8 path round-trips byte-safe and ingest accepts");
let ledger = read_candidates(root, 212).unwrap();
let row = ledger
.rows
.iter()
.find(|r| r.label == "review-001")
.unwrap();
assert_eq!(row.status, CandidateStatus::Created);
assert_eq!(row.merge_oid, r);
}
#[test]
fn ingest_taxonomy_rename_rename_third_name_refuses() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_test_repo(root);
fs::write(root.join("orig.txt"), "COMMON\n").unwrap();
git(root, &["add", "orig.txt"]);
git(root, &["commit", "-q", "-m", "common"]);
git(root, &["checkout", "-q", "-b", "source"]);
git(root, &["mv", "orig.txt", "z.txt"]);
git(root, &["commit", "-q", "-m", "source renames orig->z"]);
let source = git(root, &["rev-parse", "HEAD"]);
git(root, &["checkout", "-q", "main"]);
git(root, &["mv", "orig.txt", "y.txt"]);
git(root, &["commit", "-q", "-m", "base renames orig->y"]);
let base = git(root, &["rev-parse", "HEAD"]);
let target_ref = seed_conflicted_row(root, 212, "review-001", &base, &source);
git(root, &["checkout", "-q", "main"]);
git(root, &["rm", "-q", "y.txt"]);
fs::write(root.join("w.txt"), "RESOLVED\n").unwrap();
git(root, &["add", "-A"]);
commit_operator_merge(root, &base, &source, &target_ref);
assert!(
candidate_ingest(root, &taxonomy_req()).is_err(),
"resolving a rename/rename to a third name ∉ C is refused (§5.5 limitation)"
);
}
#[test]
fn ingest_taxonomy_modify_delete_accepts() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_test_repo(root);
fs::write(root.join("keep.txt"), "COMMON\n").unwrap();
fs::write(root.join("clean.txt"), "CLEAN\n").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "common"]);
git(root, &["checkout", "-q", "-b", "source"]);
git(root, &["rm", "-q", "keep.txt"]);
git(root, &["commit", "-q", "-m", "source deletes keep"]);
let source = git(root, &["rev-parse", "HEAD"]);
git(root, &["checkout", "-q", "main"]);
fs::write(root.join("keep.txt"), "MAIN\n").unwrap();
git(root, &["add", "keep.txt"]);
git(root, &["commit", "-q", "-m", "base modifies keep"]);
let base = git(root, &["rev-parse", "HEAD"]);
let target_ref = seed_conflicted_row(root, 212, "review-001", &base, &source);
git(root, &["checkout", "-q", "main"]);
fs::write(root.join("keep.txt"), "RESOLVED\n").unwrap();
git(root, &["add", "keep.txt"]);
let r = commit_operator_merge(root, &base, &source, &target_ref);
candidate_ingest(root, &taxonomy_req()).expect("modify/delete resolved within C accepts");
let ledger = read_candidates(root, 212).unwrap();
let row = ledger
.rows
.iter()
.find(|r| r.label == "review-001")
.unwrap();
assert_eq!(row.status, CandidateStatus::Created);
assert_eq!(row.merge_oid, r);
}
#[test]
fn ingest_taxonomy_rename_delete_accepts() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_test_repo(root);
fs::write(root.join("orig.txt"), "COMMON\n").unwrap();
fs::write(root.join("clean.txt"), "CLEAN\n").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "common"]);
git(root, &["checkout", "-q", "-b", "source"]);
git(root, &["rm", "-q", "orig.txt"]);
git(root, &["commit", "-q", "-m", "source deletes orig"]);
let source = git(root, &["rev-parse", "HEAD"]);
git(root, &["checkout", "-q", "main"]);
git(root, &["mv", "orig.txt", "renamed.txt"]);
git(root, &["commit", "-q", "-m", "base renames orig->renamed"]);
let base = git(root, &["rev-parse", "HEAD"]);
let target_ref = seed_conflicted_row(root, 212, "review-001", &base, &source);
let mb = git::merge_base(root, &base, &source).unwrap().unwrap();
let (tc, stages) = match git::merge_tree(root, &mb, &base, &source).unwrap() {
git::MergeTree::Conflict { tree, stages } => (tree, stages),
git::MergeTree::Clean { .. } => panic!("rename/delete must conflict"),
};
git(root, &["read-tree", "-u", "--reset", &tc]);
for stage in &stages {
let p = std::path::Path::new(std::ffi::OsStr::new(
std::str::from_utf8(&stage.path).expect("ascii conflict path in this fixture"),
));
fs::write(root.join(p), "RESOLVED\n").unwrap();
}
git(root, &["add", "-A"]);
let r = commit_operator_merge(root, &base, &source, &target_ref);
candidate_ingest(root, &taxonomy_req()).expect("rename/delete resolved within C accepts");
let ledger = read_candidates(root, 212).unwrap();
let row = ledger
.rows
.iter()
.find(|r| r.label == "review-001")
.unwrap();
assert_eq!(row.status, CandidateStatus::Created);
assert_eq!(row.merge_oid, r);
}
#[test]
fn run_candidate_ingest_refuses_a_candidate_worktree_cwd() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
seed_conflicted_candidate(root, 212, "review-001");
let candidate_cwd = root
.join(CANDIDATE_WORKTREE_SUBPATH)
.join("cand-212-review-001");
fs::create_dir_all(&candidate_cwd).unwrap();
let req = IngestRequest {
slice: 212,
label: "review-001".to_owned(),
ingested_at: "2026-02-02".to_owned(),
};
let err = candidate_ingest(&candidate_cwd, &req)
.expect_err("a candidate-worktree cwd is refused")
.to_string();
assert!(
err.contains("candidate checkout") && err.contains("coordination tree"),
"refusal directs to the coordination tree: {err}"
);
}
#[test]
fn run_candidate_ingest_write_once_pre_state_refuses() {
{
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
seed_conflicted_candidate(root, 212, "review-001");
let req = IngestRequest {
slice: 212,
label: "review-001".to_owned(),
ingested_at: "d".to_owned(),
};
let err = candidate_ingest(root, &req)
.expect_err("an un-committed ref refuses")
.to_string();
assert!(err.contains("still points at base"), "{err}");
}
{
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let (base, source, target_ref) = seed_conflicted_candidate(root, 212, "review-001");
operator_resolve(root, &base, &source, &target_ref, "RESOLVED\n");
let req = IngestRequest {
slice: 212,
label: "review-001".to_owned(),
ingested_at: "d".to_owned(),
};
candidate_ingest(root, &req).expect("first ingest succeeds");
let err = candidate_ingest(root, &req)
.expect_err("a Created row cannot be re-ingested")
.to_string();
assert!(err.contains("no un-ingested conflicted"), "{err}");
}
{
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
seed_conflicted_candidate(root, 212, "review-001");
let mut ledger = read_candidates(root, 212).unwrap();
let mut dup = ledger.rows.first().expect("seed row").clone();
dup.id = "cand-212-review-001-dup".to_owned();
ledger.rows.push(dup);
crate::ledger::write_candidates(root, 212, &ledger).unwrap();
let req = IngestRequest {
slice: 212,
label: "review-001".to_owned(),
ingested_at: "d".to_owned(),
};
let err = candidate_ingest(root, &req)
.expect_err("an ambiguous label refuses")
.to_string();
assert!(err.contains("ambiguous"), "{err}");
}
}
fn commit_repo() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
init_repo(&dir);
(tmp, dir)
}
fn mk(kind: ChangeKind, p: &str) -> StagedChange {
StagedChange {
kind,
path: p.to_string(),
}
}
#[test]
fn classify_commit_refuses_undeclared_path() {
let err = classify_commit(&["a.txt".into()], &[mk(ChangeKind::Modified, "b.txt")])
.expect_err("b.txt is outside the declared pathspec");
assert_eq!(err.token(), "commit-undeclared-path");
assert_eq!(err.paths(), ["b.txt"]);
}
#[test]
fn classify_commit_refuses_undeclared_deletion_under_declared_dir() {
let err = classify_commit(&["dir".into()], &[mk(ChangeKind::Deleted, "dir/gone.txt")])
.expect_err("a deletion under a declared dir must be explicitly named");
assert_eq!(err.token(), "commit-undeclared-deletion");
assert_eq!(err.paths(), ["dir/gone.txt"]);
}
#[test]
fn classify_commit_passes_and_returns_named_deletion_set() {
let changes = [
mk(ChangeKind::Deleted, "del.txt"),
mk(ChangeKind::Modified, "mod.txt"),
mk(ChangeKind::Added, "new.txt"),
];
let dels = classify_commit(
&["del.txt".into(), "mod.txt".into(), "new.txt".into()],
&changes,
)
.expect("all changes declared, the deletion explicitly named");
assert_eq!(dels, ["del.txt"], "the validated scoped-env deletion set");
}
#[test]
fn path_covered_is_component_anchored_not_substring() {
assert!(path_covered("dir/child.txt", &["dir".into()]));
assert!(
!path_covered("dir-other/x", &["dir".into()]),
"a sibling sharing a substring prefix is NOT covered"
);
assert!(
path_covered("anything/here", &[".".into()]),
"`.` covers the tree"
);
}
#[test]
fn env_token_names_are_single_sourced() {
assert_eq!(ENV_ALLOWED_DELETIONS, "DOCTRINE_ALLOWED_DELETIONS");
assert_eq!(ENV_ALLOW_DELETE, "DOCTRINE_ALLOW_DELETE");
}
fn install_recording_hook(coord: &Path, sentinel: &Path) {
let git_dir = git(coord, &["rev-parse", "--absolute-git-dir"]);
let hooks = Path::new(&git_dir).join("hooks");
std::fs::create_dir_all(&hooks).unwrap();
git(
coord,
&[
"config",
"--local",
"core.hooksPath",
hooks.to_str().unwrap(),
],
);
let hp = hooks.join("pre-commit");
std::fs::write(
&hp,
format!(
"#!/bin/sh\nprintf '%s' \"$DOCTRINE_ALLOWED_DELETIONS\" > '{}'\nexit 0\n",
sentinel.display()
),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&hp, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[test]
fn run_commit_declared_deletion_lands_and_scopes_allowed_deletions_to_child() {
let (_tmp, dir) = commit_repo();
std::fs::write(dir.join("del.txt"), "d\n").unwrap();
std::fs::write(dir.join("mod.txt"), "m0\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "seed files"]);
std::fs::remove_file(dir.join("del.txt")).unwrap();
std::fs::write(dir.join("mod.txt"), "m1\n").unwrap();
let sentinel = dir.join("sentinel");
install_recording_hook(&dir, &sentinel);
assert!(std::env::var(ENV_ALLOWED_DELETIONS).is_err());
run_commit(
Some(dir.clone()),
228,
"msg",
&["del.txt".into(), "mod.txt".into()],
)
.expect("declared deletion + modification commits cleanly");
let recorded = git(&dir, &["show", "--name-status", "--format=", "HEAD"]);
assert!(recorded.contains("D\tdel.txt"), "recorded: {recorded}");
assert!(recorded.contains("M\tmod.txt"), "recorded: {recorded}");
let recorded_deletions = std::fs::read_to_string(&sentinel).unwrap_or_else(|e| {
panic!(
"pre-commit hook never wrote {}: {e}. The hook did not run — check that \
no ambient `core.hooksPath` (global or system) is overriding the \
fixture's pinned one (ISS-256).",
sentinel.display()
)
});
assert_eq!(recorded_deletions, "del.txt");
assert!(std::env::var(ENV_ALLOWED_DELETIONS).is_err());
}
#[test]
fn run_commit_refuses_unnamed_deletion_without_touching_the_real_index() {
let (_tmp, dir) = commit_repo();
std::fs::create_dir_all(dir.join("d")).unwrap();
std::fs::write(dir.join("d/x.txt"), "x\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "seed d"]);
let head_before = git(&dir, &["rev-parse", "HEAD"]);
std::fs::remove_file(dir.join("d/x.txt")).unwrap();
let err = run_commit(Some(dir.clone()), 228, "m", &["d".into()])
.expect_err("a deletion under the declared dir was not explicitly named")
.to_string();
assert!(err.contains("commit-undeclared-deletion"), "{err}");
assert_eq!(
git(&dir, &["rev-parse", "HEAD"]),
head_before,
"no commit landed"
);
assert!(
git(&dir, &["diff", "--cached", "--name-status"]).is_empty(),
"the throwaway-index validation left the real index byte-clean"
);
}
fn repo_with_funnel_advance(path: &str, v0: &str, v1: &str) -> (tempfile::TempDir, PathBuf) {
let (tmp, dir) = commit_repo();
std::fs::write(dir.join(path), v0).unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "pre-advance"]);
std::fs::write(dir.join(path), v1).unwrap();
git(&dir, &["add", "-A"]);
git(
&dir,
&["commit", "-q", "-m", &funnel_message(228, "PHASE-01")],
);
(tmp, dir)
}
#[test]
fn is_funnel_reversion_only_when_head_advanced_and_staged_reverts() {
assert!(is_funnel_reversion(
Some("base"),
Some("head"),
Some("base")
));
assert!(!is_funnel_reversion(
Some("base"),
Some("head"),
Some("fwd")
));
assert!(!is_funnel_reversion(Some("b"), Some("b"), Some("b")));
assert!(!is_funnel_reversion(None, Some("head"), None));
}
#[test]
fn hook_check_deletion_arm_refuses_staged_deletion() {
let (_tmp, dir) = commit_repo();
std::fs::write(dir.join("f.txt"), "x\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "seed"]);
git(&dir, &["rm", "-q", "f.txt"]);
match evaluate_hook(&dir, false, &BTreeSet::new()).unwrap() {
HookOutcome::Refuse {
deletions,
reversions,
} => {
assert_eq!(deletions, ["f.txt"]);
assert!(reversions.is_empty());
}
other => panic!("expected Refuse, got {other:?}"),
}
let allow: BTreeSet<String> = ["f.txt".to_string()].into_iter().collect();
assert_eq!(
evaluate_hook(&dir, false, &allow).unwrap(),
HookOutcome::Pass
);
assert_eq!(
evaluate_hook(&dir, true, &BTreeSet::new()).unwrap(),
HookOutcome::Pass
);
}
#[test]
fn hook_check_reversion_arm_refuses_modification_only_reverse_diff() {
let (_tmp, dir) = repo_with_funnel_advance("x.txt", "v0\n", "v1\n");
std::fs::write(dir.join("x.txt"), "v0\n").unwrap();
git(&dir, &["add", "x.txt"]);
match evaluate_hook(&dir, false, &BTreeSet::new()).unwrap() {
HookOutcome::Refuse {
deletions,
reversions,
} => {
assert!(
deletions.is_empty(),
"no deletion — the deletion arm alone would wave this reversion through"
);
assert_eq!(reversions, ["x.txt"]);
}
other => panic!("expected Refuse, got {other:?}"),
}
std::fs::write(dir.join("x.txt"), "v2\n").unwrap();
git(&dir, &["add", "x.txt"]);
assert_eq!(
evaluate_hook(&dir, false, &BTreeSet::new()).unwrap(),
HookOutcome::Pass
);
}
#[test]
fn hook_check_mixed_add_modify_delete_refuses_both_arms() {
let (_tmp, dir) = repo_with_funnel_advance("x.txt", "v0\n", "v1\n");
std::fs::write(dir.join("new.txt"), "n\n").unwrap(); std::fs::write(dir.join("x.txt"), "v0\n").unwrap(); git(&dir, &["rm", "-q", "a.txt"]); git(&dir, &["add", "-A"]);
match evaluate_hook(&dir, false, &BTreeSet::new()).unwrap() {
HookOutcome::Refuse {
deletions,
reversions,
} => {
assert_eq!(deletions, ["a.txt"]);
assert_eq!(reversions, ["x.txt"], "the Added new.txt is inert");
}
other => panic!("expected Refuse, got {other:?}"),
}
}
#[test]
fn hook_check_allowed_deletion_still_refuses_sibling_reversion() {
let (_tmp, dir) = repo_with_funnel_advance("x.txt", "v0\n", "v1\n");
git(&dir, &["rm", "-q", "a.txt"]); std::fs::write(dir.join("x.txt"), "v0\n").unwrap(); git(&dir, &["add", "-A"]);
let allow: BTreeSet<String> = ["a.txt".to_string()].into_iter().collect();
match evaluate_hook(&dir, false, &allow).unwrap() {
HookOutcome::Refuse {
deletions,
reversions,
} => {
assert!(deletions.is_empty(), "the declared deletion is allowed…");
assert_eq!(
reversions,
["x.txt"],
"…but the reversion arm never sleeps (RV-304 F-5)"
);
}
other => panic!("expected Refuse, got {other:?}"),
}
assert_eq!(
evaluate_hook(&dir, true, &allow).unwrap(),
HookOutcome::Pass
);
}
#[test]
fn hook_asset_is_embedded_via_install_root() {
let bytes = crate::asset_source::read_bytes(HOOK_ASSET_KEY)
.expect("git-hooks/pre-commit is embedded via the existing install/ root");
assert!(!bytes.is_empty());
assert!(
std::str::from_utf8(&bytes)
.unwrap()
.contains("dispatch hook-check"),
"the shipped script delegates the reversion arm to `doctrine dispatch hook-check`"
);
}
#[test]
fn install_coord_hook_installs_and_chains_the_operator_hook() {
let (_tmp, _primary, coord, _base, _bt) = primary_with_coord(228);
install_coord_hook(&coord).unwrap();
assert_eq!(
git(&coord, &["config", "--bool", "extensions.worktreeConfig"]),
"true"
);
let wt_hooks = git(&coord, &["config", "--worktree", "core.hooksPath"]);
assert!(wt_hooks.contains(COORD_HOOKS_DIR), "hooks dir: {wt_hooks}");
assert!(Path::new(&wt_hooks).join("pre-commit").exists());
let op = coord.join("op-hooks");
std::fs::create_dir_all(&op).unwrap();
let sentinel = coord.join("op-fired");
std::fs::write(
op.join("pre-commit"),
format!("#!/bin/sh\ntouch '{}'\nexit 0\n", sentinel.display()),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(
op.join("pre-commit"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
}
git(
&coord,
&["config", "--local", "core.hooksPath", op.to_str().unwrap()],
);
std::fs::write(coord.join("benign.txt"), "hi\n").unwrap();
git(&coord, &["add", "benign.txt"]);
let out = std::process::Command::new("git")
.arg("-C")
.arg(&coord)
.args(["commit", "-m", "benign"])
.env("DOCTRINE_ALLOW_DELETE", "1")
.output()
.unwrap();
assert!(
out.status.success(),
"commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
sentinel.exists(),
"the operator's LOCAL-scope pre-commit fired via the chain"
);
}
#[test]
fn install_coord_hook_permits_commit_when_no_chained_hook_exists() {
let (tmp, primary, coord, _base, _bt) = primary_with_coord(228);
install_coord_hook(&coord).unwrap();
assert!(
!primary
.join(".git")
.join("hooks")
.join("pre-commit")
.exists(),
"fixture precondition: the chain fallback holds no pre-commit"
);
let stub = |name: &str, code: i32| -> PathBuf {
let p = tmp.path().join(name);
std::fs::write(&p, format!("#!/bin/sh\nexit {code}\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
p
};
let commit = |file: &str, bin: &Path| -> std::process::Output {
std::fs::write(coord.join(file), "x\n").unwrap();
git(&coord, &["add", file]);
std::process::Command::new("git")
.arg("-C")
.arg(&coord)
.args(["commit", "-m", file])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("DOCTRINE_BIN", bin)
.env_remove("DOCTRINE_ALLOW_DELETE")
.output()
.unwrap()
};
let out = commit("benign.txt", &stub("hook-check-pass", 0));
assert!(
out.status.success(),
"an absent chain target must not abort the hook: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = commit("refused.txt", &stub("hook-check-refuse", 1));
assert!(
!out.status.success(),
"a refusing hook-check must still block the commit"
);
let out = commit("failclosed.txt", &tmp.path().join("no-such-doctrine"));
assert!(
!out.status.success(),
"a missing doctrine binary must still refuse"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("fail-closed"),
"the fail-closed refusal keeps its diagnostic: {}",
String::from_utf8_lossy(&out.stderr)
);
}
mod next_oracle {
use super::super::funnel::{FunnelLanding, land_funnel_transitions};
use super::super::{
NextKind, NextSelection, PhasePrescription, dispatch_ref, next_command, next_core,
next_kind, render_next, select_next,
};
use super::{disp, primary_with_coord};
use crate::funnel_machine::{
Expected, PhaseRow, Position, SpawnRecord, StampRecord, Transition, TransitionFacts,
VerifyEvidence, VerifyStatus, expected_next,
};
use std::collections::BTreeSet;
use std::path::Path;
const SLICE: u32 = 228;
const TIP: &str = "c0ffee00";
const OTHER: &str = "deadbeef";
const AT: &str = "2026-07-26T09:00:00Z";
const FORK: &str = "dispatch/agent-a";
const BASE: &str = "ba5e0000";
fn row(id: &str, position: Position) -> PhaseRow {
PhaseRow {
id: id.to_owned(),
position,
updated_at: AT.to_owned(),
spawn: Some(SpawnRecord {
fork: FORK.to_owned(),
base_oid: BASE.to_owned(),
at: AT.to_owned(),
}),
worker_commit: None,
import: None,
verify: None,
conclude: Some(StampRecord { at: AT.to_owned() }),
reap: None,
}
}
fn with_evidence(mut r: PhaseRow, status: VerifyStatus, verified_oid: &str) -> PhaseRow {
r.verify = Some(VerifyEvidence {
status,
verified_oid: verified_oid.to_owned(),
suite: "gate".to_owned(),
at: AT.to_owned(),
});
r
}
fn prescribe(r: &PhaseRow) -> PhasePrescription<'_> {
let facts = TransitionFacts {
row: Some(r),
coord_tip: TIP,
paths_since_verify: None,
};
PhasePrescription {
expected: expected_next(Some(r.position), &facts),
row: r,
}
}
fn prescribe_with<'a>(r: &'a PhaseRow, paths: &'a [String]) -> PhasePrescription<'a> {
let facts = TransitionFacts {
row: Some(r),
coord_tip: TIP,
paths_since_verify: Some(paths),
};
PhasePrescription {
expected: expected_next(Some(r.position), &facts),
row: r,
}
}
fn recorded<'a>(rows: &[&'a PhaseRow]) -> BTreeSet<&'a str> {
rows.iter().map(|r| r.id.as_str()).collect()
}
fn oracle(
mid: &[PhasePrescription<'_>],
known: &BTreeSet<&str>,
ready: &[String],
) -> super::super::NextCore {
let selection = select_next(mid, known, ready);
render_next(SLICE, TIP, &selection, mid)
}
#[test]
fn the_oracle_is_total_and_agrees_with_expected_next() {
let positions = [
Position::Spawned,
Position::WorkerCommitted,
Position::Imported,
Position::Verified,
Position::Concluded,
Position::Reaped,
];
let evidence = [
None,
Some((VerifyStatus::Pass, TIP)),
Some((VerifyStatus::Pass, OTHER)),
Some((VerifyStatus::Fail, TIP)),
Some((VerifyStatus::Fail, OTHER)),
];
let funnel_only = vec![format!(".doctrine/dispatch/{SLICE:03}/funnel.toml")];
let code = vec!["src/dispatch.rs".to_owned()];
let path_sets: [Option<&[String]>; 4] =
[None, Some(&[]), Some(&funnel_only), Some(&code)];
let mut seen = BTreeSet::new();
for position in positions {
for ev in &evidence {
for paths in path_sets {
let r = match ev {
Some((status, oid)) => {
with_evidence(row("PHASE-02", position), *status, oid)
}
None => row("PHASE-02", position),
};
let facts = TransitionFacts {
row: Some(&r),
coord_tip: TIP,
paths_since_verify: paths,
};
let expected = expected_next(Some(position), &facts);
let want = next_kind(expected, Some(position));
seen.insert(want.as_str());
let mid: Vec<PhasePrescription<'_>> = if position == Position::Reaped {
Vec::new()
} else {
vec![PhasePrescription { expected, row: &r }]
};
let known = recorded(&[&r]);
let core = oracle(&mid, &known, &[]);
assert_eq!(
core.kind, want,
"position {position:?} / evidence {ev:?} / paths {paths:?} \
must project the machine's own prescription {expected:?}"
);
if position == Position::Reaped {
assert_eq!(core.kind, NextKind::AllReaped);
assert_eq!(core.phase, None);
} else {
assert_eq!(core.phase.as_deref(), Some("PHASE-02"));
}
assert!(!core.detail.is_empty(), "every rung explains itself");
}
}
}
for want in [
"await-worker",
"import",
"verify",
"reverify-stale",
"triage-verify-failure",
"conclude",
"reap",
"all-reaped",
] {
assert!(seen.contains(want), "totality sweep never reached {want}");
}
}
#[test]
fn only_the_verify_arm_reads_the_position() {
assert_eq!(
next_kind(Expected::Verify, Some(Position::Imported)),
NextKind::Verify
);
assert_eq!(
next_kind(Expected::Verify, Some(Position::Verified)),
NextKind::ReverifyStale
);
for expected in [
Expected::Spawn,
Expected::AwaitWorker,
Expected::Import,
Expected::Triage,
Expected::Conclude,
Expected::Reap,
Expected::Terminal,
] {
let at_none = next_kind(expected, None);
for position in [
Position::Spawned,
Position::WorkerCommitted,
Position::Imported,
Position::Verified,
Position::Concluded,
Position::Reaped,
] {
assert_eq!(
next_kind(expected, Some(position)),
at_none,
"{expected:?} must not read the position"
);
}
}
}
#[test]
fn red_evidence_triages_globally_over_a_lower_id_runnable() {
let runnable = row("PHASE-02", Position::WorkerCommitted);
let red = with_evidence(row("PHASE-04", Position::Imported), VerifyStatus::Fail, TIP);
let mid = vec![prescribe(&runnable), prescribe(&red)];
let known = recorded(&[&runnable, &red]);
let core = oracle(&mid, &known, &[]);
assert_eq!(core.kind, NextKind::TriageVerifyFailure);
assert_eq!(core.kind.as_str(), "triage-verify-failure");
assert_eq!(core.phase.as_deref(), Some("PHASE-04"));
assert_eq!(core.command, None, "a judgment beat carries no command");
assert!(
core.detail.contains("PHASE-02"),
"the other in-flight phase is surfaced at every rung: {}",
core.detail
);
}
#[test]
fn await_worker_never_starves_a_runnable_import() {
let awaiting = row("PHASE-01", Position::Spawned);
let importable = row("PHASE-03", Position::WorkerCommitted);
let mid = vec![prescribe(&awaiting), prescribe(&importable)];
let known = recorded(&[&awaiting, &importable]);
let core = oracle(&mid, &known, &[]);
assert_eq!(core.kind, NextKind::Import);
assert_eq!(core.phase.as_deref(), Some("PHASE-03"));
assert_eq!(
core.command.as_deref(),
Some(format!("dispatch_import{{slice: {SLICE}, name: \"{FORK}\"}}").as_str())
);
assert!(
core.detail.contains("PHASE-01") && core.detail.contains("await-worker"),
"the awaited sibling is surfaced: {}",
core.detail
);
}
#[test]
fn mixed_positions_pick_the_lowest_id_runnable() {
let awaiting = row("PHASE-01", Position::Spawned);
let concludable =
with_evidence(row("PHASE-02", Position::Verified), VerifyStatus::Pass, TIP);
let reapable = row("PHASE-03", Position::Concluded);
let paths: Vec<String> = Vec::new();
let mid = vec![
prescribe(&awaiting),
prescribe_with(&concludable, &paths),
prescribe(&reapable),
];
let known = recorded(&[&awaiting, &concludable, &reapable]);
let core = oracle(&mid, &known, &[]);
assert_eq!(core.kind, NextKind::Conclude);
assert_eq!(core.phase.as_deref(), Some("PHASE-02"));
assert_eq!(
core.command.as_deref(),
Some(
format!(
"dispatch_conclude_phase{{slice: {SLICE}, phase: \"PHASE-02\", \
code_start: \"{BASE}\", code_end: \"{TIP}\"}}"
)
.as_str()
)
);
assert!(core.detail.contains("PHASE-03"), "{}", core.detail);
}
#[test]
fn all_awaiting_yields_await_worker_naming_every_awaited_phase() {
let a = row("PHASE-01", Position::Spawned);
let b = row("PHASE-02", Position::Spawned);
let mid = vec![prescribe(&a), prescribe(&b)];
let known = recorded(&[&a, &b]);
let core = oracle(&mid, &known, &["PHASE-09".to_owned()]);
assert_eq!(core.kind, NextKind::AwaitWorker);
assert_eq!(core.kind.as_str(), "await-worker");
assert_eq!(core.phase.as_deref(), Some("PHASE-01"));
assert_eq!(core.command, None, "waiting is not a verb");
assert!(core.detail.contains("PHASE-02"), "{}", core.detail);
assert!(
!core.detail.contains("PHASE-09"),
"readiness is rung 4 only — a mid-funnel batch never reads it: {}",
core.detail
);
}
#[test]
fn spawn_takes_the_first_ready_phase_with_no_funnel_row() {
let reaped = row("PHASE-01", Position::Reaped);
let known = recorded(&[&reaped]);
let ready = vec!["PHASE-01".to_owned(), "PHASE-02".to_owned()];
let core = oracle(&[], &known, &ready);
assert_eq!(core.kind, NextKind::Spawn);
assert_eq!(core.phase.as_deref(), Some("PHASE-02"));
assert_eq!(
core.command, None,
"spawn is ARM-ROUTED — the oracle stays arm-agnostic (D-P6-5)"
);
assert!(
core.detail.contains("arm"),
"spawn hands off to the arm skill: {}",
core.detail
);
}
#[test]
fn all_reaped_is_terminal_and_hands_off_to_dispatch_status() {
let reaped = row("PHASE-01", Position::Reaped);
let known = recorded(&[&reaped]);
let core = oracle(&[], &known, &["PHASE-01".to_owned()]);
assert_eq!(core.kind, NextKind::AllReaped);
assert_eq!(core.kind.as_str(), "all-reaped");
assert_eq!(core.phase, None);
assert_eq!(core.command, None);
assert!(
core.detail.contains("dispatch status"),
"the terminal beat hands off to the OTHER altitude (R5): {}",
core.detail
);
}
#[test]
fn an_empty_funnel_record_spawns_the_first_ready_phase() {
let core = oracle(&[], &BTreeSet::new(), &["PHASE-01".to_owned()]);
assert_eq!(core.kind, NextKind::Spawn);
assert_eq!(core.phase.as_deref(), Some("PHASE-01"));
}
#[test]
fn a_row_without_a_spawn_record_renders_no_command() {
let mut r = row("PHASE-01", Position::WorkerCommitted);
r.spawn = None;
let mid = vec![prescribe(&r)];
let known = recorded(&[&r]);
let core = oracle(&mid, &known, &[]);
assert_eq!(core.kind, NextKind::Import);
assert_eq!(core.command, None);
assert!(core.detail.contains("spawn record"), "{}", core.detail);
}
#[test]
fn commands_name_the_surface_that_owns_the_verb() {
let importable = row("PHASE-01", Position::Imported);
assert_eq!(
next_command(NextKind::Verify, SLICE, TIP, Some(&importable)).unwrap(),
Some(format!(
"doctrine dispatch verify --slice {SLICE} --phase PHASE-01"
))
);
let concluded = row("PHASE-01", Position::Concluded);
assert_eq!(
next_command(NextKind::Reap, SLICE, TIP, Some(&concluded)).unwrap(),
Some(format!("dispatch_reap{{slice: {SLICE}, name: \"{FORK}\"}}"))
);
for kind in [
NextKind::Spawn,
NextKind::AwaitWorker,
NextKind::TriageVerifyFailure,
NextKind::AllReaped,
] {
assert_eq!(
next_command(kind, SLICE, TIP, Some(&concluded)).unwrap(),
None,
"{kind:?} carries no command literal"
);
}
}
#[test]
fn the_ladder_selects_exactly_one_phase() {
let a = row("PHASE-01", Position::Spawned);
let b = row("PHASE-02", Position::WorkerCommitted);
let c = row("PHASE-03", Position::Concluded);
let mid = vec![prescribe(&a), prescribe(&b), prescribe(&c)];
let known = recorded(&[&a, &b, &c]);
match select_next(&mid, &known, &[]) {
NextSelection::Prescribed { kind, row } => {
assert_eq!(kind, NextKind::Import);
assert_eq!(row.id, "PHASE-02");
}
other => panic!("expected a mid-funnel prescription, got {other:?}"),
}
}
#[test]
fn next_core_prescribes_import_from_the_committed_record() {
let (_tmp, _primary, coord, base, _bt) = primary_with_coord(SLICE);
let tip = land(
&coord,
&base,
&[
Transition::Spawn {
fork: FORK.to_owned(),
base_oid: base.clone(),
},
Transition::RecordWorkerCommit {
fork_tip: OTHER.to_owned(),
},
],
);
let core = next_core(&coord, &tip, SLICE).unwrap();
assert_eq!(core.kind, NextKind::Import);
assert_eq!(core.phase.as_deref(), Some("PHASE-01"));
assert_eq!(
core.command.as_deref(),
Some(format!("dispatch_import{{slice: {SLICE}, name: \"{FORK}\"}}").as_str())
);
}
fn land(coord: &Path, tip: &str, ts: &[Transition]) -> String {
match land_funnel_transitions(
coord,
&dispatch_ref(SLICE),
tip,
SLICE,
"PHASE-01",
ts,
None,
None,
"dispatch-funnel: SL-228 PHASE-01",
AT,
&disp(),
)
.unwrap()
{
FunnelLanding::Landed { oid, .. } => oid,
other => panic!("expected Landed, got {other:?}"),
}
}
}
}