use crate::{entity, kinds};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusClass {
Workable,
Gating,
Terminal,
Unrecognised,
}
struct KindPartition {
prefix: &'static str,
workable: &'static [&'static str],
gating: &'static [&'static str],
terminal: &'static [&'static str],
}
const PARTITION: &[KindPartition] = &[
KindPartition {
prefix: kinds::SL,
workable: &[
"proposed",
"design",
"plan",
"ready",
"started",
"audit",
"reconcile",
],
gating: &[],
terminal: &["done", "abandoned"],
},
KindPartition {
prefix: kinds::ADR,
workable: &["proposed"],
gating: &[],
terminal: &["accepted", "rejected", "superseded", "deprecated"],
},
KindPartition {
prefix: kinds::POL,
workable: &["draft"],
gating: &[],
terminal: &["required", "superseded", "deprecated", "retired"],
},
KindPartition {
prefix: kinds::STD,
workable: &["draft"],
gating: &[],
terminal: &["default", "required", "superseded", "deprecated", "retired"],
},
KindPartition {
prefix: kinds::PRD,
workable: &["draft"],
gating: &[],
terminal: &["active", "deprecated", "superseded"],
},
KindPartition {
prefix: kinds::SPEC,
workable: &["draft"],
gating: &[],
terminal: &["active", "deprecated", "superseded"],
},
KindPartition {
prefix: kinds::REQ,
workable: &["pending", "in-progress"],
gating: &[],
terminal: &["active", "deprecated", "retired", "superseded"],
},
KindPartition {
prefix: kinds::ISS,
workable: BACKLOG_WORKABLE,
gating: &[],
terminal: BACKLOG_TERMINAL,
},
KindPartition {
prefix: kinds::IMP,
workable: BACKLOG_WORKABLE,
gating: &[],
terminal: BACKLOG_TERMINAL,
},
KindPartition {
prefix: kinds::CHR,
workable: BACKLOG_WORKABLE,
gating: &[],
terminal: BACKLOG_TERMINAL,
},
KindPartition {
prefix: kinds::RSK,
workable: BACKLOG_WORKABLE,
gating: &[],
terminal: BACKLOG_TERMINAL,
},
KindPartition {
prefix: kinds::IDE,
workable: BACKLOG_WORKABLE,
gating: &[],
terminal: BACKLOG_TERMINAL,
},
KindPartition {
prefix: kinds::RV,
workable: &["active"],
gating: &[],
terminal: &["done"],
},
KindPartition {
prefix: kinds::REV,
workable: &["proposed", "started"],
gating: &[],
terminal: &["done", "abandoned"],
},
KindPartition {
prefix: kinds::ASM,
workable: &[],
gating: &["held", "testing"],
terminal: &["validated", "invalidated", "obsolete"],
},
KindPartition {
prefix: kinds::DEC,
workable: &[],
gating: &["proposed"],
terminal: &["accepted", "rejected", "superseded"],
},
KindPartition {
prefix: kinds::QUE,
workable: &[],
gating: &["open"],
terminal: &["answered", "obsolete"],
},
KindPartition {
prefix: kinds::CON,
workable: &[],
gating: &["active"],
terminal: &["waived", "superseded", "retired"],
},
KindPartition {
prefix: kinds::EVD,
workable: &[],
gating: &["captured", "disputed"],
terminal: &["confirmed", "retracted", "superseded"],
},
KindPartition {
prefix: kinds::HYP,
workable: &[],
gating: &["proposed"],
terminal: &["confirmed", "refuted"],
},
];
const BACKLOG_WORKABLE: &[&str] = &["open", "triaged", "started"];
const BACKLOG_TERMINAL: &[&str] = &["resolved", "closed"];
pub(crate) fn status_class(kind: &entity::Kind, status: Option<&str>) -> StatusClass {
let Some(status) = status else {
return StatusClass::Terminal;
};
let Some(part) = PARTITION.iter().find(|p| p.prefix == kind.prefix) else {
return StatusClass::Unrecognised;
};
if part.workable.contains(&status) {
StatusClass::Workable
} else if part.gating.contains(&status) {
StatusClass::Gating
} else if part.terminal.contains(&status) {
StatusClass::Terminal
} else {
StatusClass::Unrecognised
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::SCHEMA_KNOWLEDGE;
use crate::{
adr, backlog, knowledge, policy, requirement, review, revision, slice, spec, standard,
};
use std::collections::BTreeSet;
fn part(prefix: &str) -> &'static KindPartition {
PARTITION
.iter()
.find(|p| p.prefix == prefix)
.expect("prefix in PARTITION")
}
fn vocab(prefix: &str) -> BTreeSet<&'static str> {
let p = part(prefix);
p.workable
.iter()
.chain(p.gating.iter())
.chain(p.terminal.iter())
.copied()
.collect()
}
fn set(items: &[&'static str]) -> BTreeSet<&'static str> {
items.iter().copied().collect()
}
#[test]
fn slice_partition_binds_adr009_lifecycle_vocabulary() {
assert_eq!(vocab("SL"), set(slice::SLICE_STATUSES));
}
#[test]
fn adr_partition_covers_the_real_vocabulary() {
assert_eq!(vocab("ADR"), set(adr::ADR_STATUSES));
}
#[test]
fn policy_partition_covers_the_real_vocabulary() {
assert_eq!(vocab("POL"), set(policy::POLICY_STATUSES));
}
#[test]
fn standard_partition_covers_the_real_vocabulary() {
assert_eq!(vocab("STD"), set(standard::STANDARD_STATUSES));
}
#[test]
fn prd_and_tech_spec_partitions_cover_the_real_vocabulary() {
assert_eq!(vocab("PRD"), set(spec::SPEC_STATUSES));
assert_eq!(vocab("SPEC"), set(spec::SPEC_STATUSES));
}
#[test]
fn requirement_partition_covers_the_real_vocabulary() {
assert_eq!(vocab("REQ"), set(requirement::REQ_STATUSES));
}
#[test]
fn backlog_partition_covers_the_real_vocabulary() {
for prefix in ["ISS", "IMP", "CHR", "RSK", "IDE"] {
assert_eq!(
vocab(prefix),
set(backlog::BACKLOG_STATUSES),
"{prefix} partition matches BACKLOG_STATUSES"
);
}
}
#[test]
fn review_partition_covers_the_real_vocabulary() {
assert_eq!(vocab("RV"), set(review::REVIEW_STATUSES));
}
#[test]
fn revision_partition_covers_the_real_vocabulary() {
assert_eq!(vocab("REV"), set(revision::REV_STATUSES));
}
#[test]
fn revision_done_and_abandoned_classify_terminal() {
assert_eq!(
status_class(&revision::REV_KIND, Some("done")),
StatusClass::Terminal
);
assert_eq!(
status_class(&revision::REV_KIND, Some("abandoned")),
StatusClass::Terminal
);
assert_eq!(
status_class(&revision::REV_KIND, Some("proposed")),
StatusClass::Workable
);
assert_eq!(
status_class(&revision::REV_KIND, Some("started")),
StatusClass::Workable
);
}
#[test]
fn knowledge_partitions_cover_the_real_vocabularies() {
for kind in knowledge::RecordKind::ALL {
let prefix = kind.prefix();
assert_eq!(
vocab(prefix),
set(knowledge::statuses(kind)),
"{prefix} partition matches statuses({kind:?})"
);
}
}
#[test]
fn knowledge_unsettled_gating_settled_terminal() {
assert_eq!(
status_class(&knowledge::ASSUMPTION_KIND, Some("held")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::ASSUMPTION_KIND, Some("testing")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::ASSUMPTION_KIND, Some("validated")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::ASSUMPTION_KIND, Some("invalidated")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::ASSUMPTION_KIND, Some("obsolete")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::DECISION_KIND, Some("proposed")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::DECISION_KIND, Some("accepted")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::DECISION_KIND, Some("rejected")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::DECISION_KIND, Some("superseded")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::QUESTION_KIND, Some("open")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::QUESTION_KIND, Some("answered")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::QUESTION_KIND, Some("obsolete")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::CONSTRAINT_KIND, Some("active")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::CONSTRAINT_KIND, Some("waived")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::CONSTRAINT_KIND, Some("superseded")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::CONSTRAINT_KIND, Some("retired")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::EVIDENCE_KIND, Some("captured")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::EVIDENCE_KIND, Some("disputed")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::EVIDENCE_KIND, Some("confirmed")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::EVIDENCE_KIND, Some("retracted")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::EVIDENCE_KIND, Some("superseded")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::HYPOTHESIS_KIND, Some("proposed")),
StatusClass::Gating
);
assert_eq!(
status_class(&knowledge::HYPOTHESIS_KIND, Some("confirmed")),
StatusClass::Terminal
);
assert_eq!(
status_class(&knowledge::HYPOTHESIS_KIND, Some("refuted")),
StatusClass::Terminal
);
}
#[test]
fn knowledge_gating_statuses_are_not_workable() {
for kind in knowledge::RecordKind::ALL {
for status in knowledge::statuses(kind) {
let class = status_class(kind.kind(), Some(status));
if class == StatusClass::Gating {
assert!(
class != StatusClass::Workable,
"{:?}/{status} is Gating — must not be Workable",
kind
);
}
}
}
}
#[test]
fn every_knowledge_status_classifies_gating_or_terminal_never_workable() {
for kind in knowledge::RecordKind::ALL {
for status in knowledge::statuses(kind) {
let class = status_class(kind.kind(), Some(status));
assert!(
class == StatusClass::Gating || class == StatusClass::Terminal,
"{:?}/{status} must be Gating or Terminal, got {class:?}",
kind
);
assert_ne!(
class,
StatusClass::Workable,
"{:?}/{status} must never be Workable",
kind
);
}
}
}
#[test]
fn decision_accepted_diverges_hidden_from_status_class() {
assert!(!knowledge::is_hidden(
knowledge::RecordKind::Decision,
"accepted"
));
assert_eq!(
status_class(&knowledge::DECISION_KIND, Some("accepted")),
StatusClass::Terminal
);
}
#[test]
fn knowledge_gating_and_terminal_e2e_golden() {
use crate::priority::graph;
use crate::relation_graph::EntityKey;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".doctrine/doctrine")).unwrap();
std::fs::write(
root.join(".doctrine/doctrine.toml"),
"[doctrine]\nversion = \"0.1.0\"\n",
)
.unwrap();
let slice_dir = root.join(".doctrine/slice/001");
std::fs::create_dir_all(&slice_dir).unwrap();
std::fs::write(
slice_dir.join("slice-001.toml"),
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n",
)
.unwrap();
std::fs::write(slice_dir.join("slice-001.md"), "scope\n").unwrap();
let que_dir = root.join(".doctrine/knowledge/question/001");
std::fs::create_dir_all(&que_dir).unwrap();
std::fs::write(
que_dir.join("record-001.toml"),
format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\
id = 1\nslug = \"q1\"\ntitle = \"Q1\"\n\
record_kind = \"question\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n"
),
)
.unwrap();
std::fs::write(que_dir.join("record-001.md"), "open question\n").unwrap();
let que2_dir = root.join(".doctrine/knowledge/question/002");
std::fs::create_dir_all(&que2_dir).unwrap();
std::fs::write(
que2_dir.join("record-002.toml"),
format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\
id = 2\nslug = \"q2\"\ntitle = \"Q2\"\n\
record_kind = \"question\"\nstatus = \"answered\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n"
),
)
.unwrap();
std::fs::write(que2_dir.join("record-002.md"), "answered question\n").unwrap();
let g = graph::build(root).unwrap();
let que1 = EntityKey {
prefix: "QUE",
id: 1,
};
let que2 = EntityKey {
prefix: "QUE",
id: 2,
};
use crate::priority::channels;
assert!(
!channels::eligible(&g, que1.clone()),
"QUE-001 (open) is Gating — not eligible"
);
assert!(
!channels::eligible(&g, que2.clone()),
"QUE-002 (answered) is Terminal — not eligible"
);
assert_eq!(
status_class(&knowledge::QUESTION_KIND, Some("open")),
StatusClass::Gating,
"open question → Gating"
);
assert_eq!(
status_class(&knowledge::QUESTION_KIND, Some("answered")),
StatusClass::Terminal,
"answered question → Terminal"
);
}
#[test]
fn non_knowledge_rows_have_empty_gating() {
for p in super::PARTITION.iter() {
if crate::kinds::is_record(p.prefix) {
continue;
}
assert!(
p.gating.is_empty(),
"non-knowledge row {} must have gating: &[]",
p.prefix
);
}
}
#[test]
fn rec_status_less_is_terminal_no_diagnostic() {
assert_eq!(
status_class(&crate::rec::REC_KIND, None),
StatusClass::Terminal
);
}
#[test]
fn unrecognised_status_is_its_own_class() {
assert_eq!(
status_class(&slice::SLICE_KIND, Some("not-a-real-status")),
StatusClass::Unrecognised
);
}
#[test]
fn workable_and_terminal_lookups() {
assert_eq!(
status_class(&slice::SLICE_KIND, Some("design")),
StatusClass::Workable
);
assert_eq!(
status_class(&slice::SLICE_KIND, Some("audit")),
StatusClass::Workable
);
assert_eq!(
status_class(&slice::SLICE_KIND, Some("reconcile")),
StatusClass::Workable
);
assert_eq!(
status_class(&slice::SLICE_KIND, Some("done")),
StatusClass::Terminal
);
}
#[test]
fn rv_derived_status_resolves_through_the_table() {
assert_eq!(
status_class(&review::REVIEW_KIND, Some("active")),
StatusClass::Workable
);
assert_eq!(
status_class(&review::REVIEW_KIND, Some("done")),
StatusClass::Terminal
);
}
}