use std::path::PathBuf;
fn resolve_supersede_path(
root: &std::path::Path,
kref: &crate::integrity::KindRef,
id: u32,
) -> (PathBuf, String) {
let toml_path = crate::entity::id_path(root, kref.kind, id, crate::entity::Ext::Toml);
(
toml_path,
crate::listing::canonical_id(kref.kind.prefix, id),
)
}
pub(crate) fn run_supersede(
path: Option<PathBuf>,
new: &str,
old: &str,
) -> anyhow::Result<()> {
use anyhow::Context;
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let (new_kref, new_id) = crate::integrity::parse_canonical_ref(new)?;
let (old_kref, old_id) = crate::integrity::parse_canonical_ref(old)?;
anyhow::ensure!(
!(new_kref.kind.prefix == old_kref.kind.prefix && new_id == old_id),
"`{new}` cannot supersede itself — a self-supersession is not a decision change"
);
let new_is_adr = new_kref.kind.prefix == "ADR";
let old_is_adr = old_kref.kind.prefix == "ADR";
let new_is_record =
crate::knowledge::RecordKind::from_prefix(new_kref.kind.prefix).is_some();
let old_is_record =
crate::knowledge::RecordKind::from_prefix(old_kref.kind.prefix).is_some();
let same_family = if new_is_adr && old_is_adr {
true } else if new_is_record && old_is_record {
let Some(new_record_kind) = crate::knowledge::RecordKind::from_prefix(new_kref.kind.prefix)
else {
anyhow::bail!("NEW kind not a valid record kind")
};
let Some(old_record_kind) = crate::knowledge::RecordKind::from_prefix(old_kref.kind.prefix)
else {
anyhow::bail!("OLD kind not a valid record kind")
};
anyhow::ensure!(
crate::supersede::validate_matrix(new_record_kind, old_record_kind),
"cross-kind supersession refused: the §6 matrix disallows {} → {}",
new_kref.kind.prefix,
old_kref.kind.prefix
);
true } else if new_kref.kind.prefix == old_kref.kind.prefix {
true } else {
false };
anyhow::ensure!(
same_family,
"cross-family supersession refused: `{new}` is a {} but `{old}` is a {}",
new_kref.kind.prefix,
old_kref.kind.prefix
);
let policy = crate::supersede::supersede_policy(new_kref.kind).with_context(|| {
format!(
"supersession not yet supported for {} (follow-up F2)",
new_kref.kind.prefix
)
})?;
let old_policy =
if !new_is_adr && !old_is_adr && new_kref.kind.prefix != old_kref.kind.prefix {
crate::supersede::supersede_policy(old_kref.kind).with_context(|| {
format!(
"supersession not yet supported for OLD {} (follow-up F2)",
old_kref.kind.prefix
)
})?
} else {
policy
};
let (new_path, new_ref) = resolve_supersede_path(&root, new_kref, new_id);
let (old_path, old_ref) = resolve_supersede_path(&root, old_kref, old_id);
let new_text = std::fs::read_to_string(&new_path)
.with_context(|| format!("supersede: {new} not found at {}", new_path.display()))?;
let mut new_doc = new_text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", new_path.display()))?;
let old_text = std::fs::read_to_string(&old_path)
.with_context(|| format!("supersede: {old} not found at {}", old_path.display()))?;
let mut old_doc = old_text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", old_path.display()))?;
let old_carveout = rel_array(&old_doc, policy.carveout_field);
anyhow::ensure!(
old_carveout.is_some(),
"malformed `{old}` at {}: missing seeded `[relationships].{}` array — restore the seeded `[relationships]` arrays before superseding; the file is left untouched",
old_path.display(),
policy.carveout_field
);
anyhow::ensure!(
old_doc
.get("status")
.and_then(toml_edit::Item::as_str)
.is_some(),
"malformed `{old}` at {}: missing seeded top-level `status` — restore the seeded keys before superseding; the file is left untouched",
old_path.display()
);
anyhow::ensure!(
old_doc
.get("updated")
.and_then(toml_edit::Item::as_str)
.is_some(),
"malformed `{old}` at {}: missing seeded top-level `updated` — restore the seeded keys before superseding; the file is left untouched",
old_path.display()
);
let old_status = old_doc
.get("status")
.and_then(toml_edit::Item::as_str)
.unwrap_or_default()
.to_string();
match policy.storage {
crate::supersede::StorageTarget::RelationRow => {
use crate::relation::{self, RelationLabel};
let relation_doc = crate::relation::RelationDoc::parse(&new_text)
.with_context(|| {
format!(
"malformed `{new}` at {}: missing seeded `[[relation]]` table — restore the seeded template; the file is left untouched",
new_path.display()
)
})?;
let (edges, _illegal) = relation::read_block(new_kref.kind, &relation_doc);
let existing_supersedes: Vec<_> = edges
.iter()
.filter(|e| e.label == RelationLabel::Supersedes)
.collect();
if old_status == policy.superseded_status {
let carveout = old_carveout.unwrap_or_default();
let new_lists_old = existing_supersedes.iter().any(|e| e.target == old_ref);
let single_self = carveout.len() == 1 && carveout.first() == Some(&new_ref);
if single_self && new_lists_old {
writeln!(
std::io::stdout(),
"already recorded: {new} supersedes {old}"
)?;
return Ok(());
}
if let Some(other) = carveout.iter().find(|x| **x != new_ref) {
anyhow::bail!("{old} already superseded by {other}; reopening is deferred");
}
anyhow::bail!(
"{old} status is superseded but its superseded_by carve-out is empty/inconsistent — run `doctrine validate`"
);
}
if let Some(edge) = existing_supersedes.first() {
anyhow::bail!("{new} already supersedes {}", edge.target);
}
let outcome = relation::append_edge(&new_path, RelationLabel::Supersedes, &old_ref)?;
if matches!(outcome, relation::AppendOutcome::Noop) {
writeln!(
std::io::stdout(),
"already recorded: {new} supersedes {old}"
)?;
} else {
writeln!(std::io::stdout(), "{new} supersedes {old}")?;
}
let today = crate::clock::today();
let status_hint = format!(
"malformed `{old}`: missing seeded top-level `status`/`updated` — restore the seeded keys; the file is left untouched"
);
crate::dep_seq::apply_string_append(&mut old_doc, policy.carveout_field, &new_ref)?;
crate::dep_seq::apply_status(
&mut old_doc,
&[("status", policy.superseded_status), ("updated", &today)],
&status_hint,
)?;
crate::fsutil::write_atomic(&old_path, old_doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", old_path.display()))?;
}
crate::supersede::StorageTarget::TypedArray { field } => {
let new_sup = rel_array(&new_doc, field);
anyhow::ensure!(
new_sup.is_some(),
"malformed `{new}` at {}: missing seeded `[relationships].{}` array — restore the seeded `[relationships]` arrays before superseding; the file is left untouched",
new_path.display(),
field
);
if old_status == old_policy.superseded_status {
let carveout = old_carveout.unwrap_or_default();
let new_lists_old = new_sup.unwrap_or_default().contains(&old_ref);
let single_self = carveout.len() == 1 && carveout.first() == Some(&new_ref);
if single_self && new_lists_old {
writeln!(
std::io::stdout(),
"already recorded: {new} supersedes {old}"
)?;
return Ok(());
}
if let Some(other) = carveout.iter().find(|x| **x != new_ref) {
anyhow::bail!("{old} already superseded by {other}; reopening is deferred");
}
anyhow::bail!(
"{old} status is superseded but its superseded_by carve-out is empty/inconsistent — run `doctrine validate`"
);
}
let today = crate::clock::today();
let status_hint = format!(
"malformed `{old}`: missing seeded top-level `status`/`updated` — restore the seeded keys; the file is left untouched"
);
crate::dep_seq::apply_string_append(&mut new_doc, field, &old_ref)?;
crate::dep_seq::apply_string_append(&mut old_doc, policy.carveout_field, &new_ref)?;
let old_record_kind =
crate::knowledge::RecordKind::from_prefix(old_kref.kind.prefix)
.context("OLD kind not a valid record kind")?;
if old_record_kind.is_terminal(&old_status) {
old_doc
.as_table_mut()
.insert("updated", toml_edit::value(today.as_str()));
} else {
crate::dep_seq::apply_status(
&mut old_doc,
&[
("status", old_policy.superseded_status),
("updated", &today),
],
&status_hint,
)?;
}
crate::fsutil::write_atomic(&new_path, new_doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", new_path.display()))?;
crate::fsutil::write_atomic(&old_path, old_doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", old_path.display()))?;
writeln!(std::io::stdout(), "{new} supersedes {old}")?;
}
}
Ok(())
}
fn rel_array(doc: &toml_edit::DocumentMut, field: &str) -> Option<Vec<String>> {
doc.get("relationships")
.and_then(toml_edit::Item::as_table)
.and_then(|t| t.get(field))
.and_then(toml_edit::Item::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog;
#[test]
fn supersede_recovery_from_torn_new_only_state() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = [\"ADR-002\"]\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/001/adr-001.md", "body\n");
catalog::test_helpers::write(
root,
".doctrine/adr/002/adr-002.toml",
"id = 2\nslug = \"a2\"\ntitle = \"A2\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/002/adr-002.md", "body\n");
run_supersede(Some(root.to_path_buf()), "ADR-001", "ADR-002")
.expect("recovery supersede should succeed");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/adr/002/adr-002.toml")).unwrap();
assert!(
old_toml.contains("status = \"obsolete\""),
"OLD.status should be obsolete, got: {old_toml}"
);
assert!(
old_toml.contains("superseded_by = [\"ADR-001\"]"),
"OLD.superseded_by should contain ADR-001, got: {old_toml}"
);
let new_toml =
std::fs::read_to_string(root.join(".doctrine/adr/001/adr-001.toml")).unwrap();
assert!(
new_toml.contains("[[relation]]")
&& new_toml.contains("label = \"supersedes\"")
&& new_toml.contains("target = \"ADR-002\""),
"NEW should have [[relation]] supersedes → ADR-002: {new_toml}"
);
}
#[test]
fn supersede_same_kind_record_allowed() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.toml",
"id = 2\nslug = \"a2\"\ntitle = \"A2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "ASM-001", "ASM-002")
.expect("same-kind record supersession should succeed");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/assumption/002/record-002.toml"))
.unwrap();
assert!(
old_toml.contains("status = \"obsolete\""),
"OLD.status should be obsolete, got: {old_toml}"
);
assert!(
old_toml.contains("superseded_by = [\"ASM-001\"]"),
"OLD.superseded_by should contain ASM-001, got: {old_toml}"
);
let new_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/assumption/001/record-001.toml"))
.unwrap();
assert!(
new_toml.contains("supersedes = [\"ASM-002\"]"),
"NEW.supersedes should contain ASM-002, got: {new_toml}"
);
}
#[test]
fn supersede_cross_kind_allowed_matrix() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "ASM-001", "DEC-002")
.expect("cross-kind supersession ASM→DEC should succeed");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/decision/002/record-002.toml"))
.unwrap();
assert!(
old_toml.contains("status = \"obsolete\""),
"OLD.status should be obsolete, got: {old_toml}"
);
assert!(
old_toml.contains("\"ASM-001\""),
"OLD.superseded_by should contain ASM-001, got: {old_toml}"
);
}
#[test]
fn supersede_cross_kind_refused_matrix() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.toml",
"id = 2\nslug = \"q2\"\ntitle = \"Q2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.md",
"body\n",
);
let result = run_supersede(Some(root.to_path_buf()), "ASM-001", "QUE-002");
assert!(
result.is_err(),
"cross-kind ASM→QUE should be refused by matrix"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("disallows"),
"error should mention matrix, got: {err}"
);
}
#[test]
fn supersede_question_reopening_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.toml",
"id = 1\nslug = \"q1\"\ntitle = \"Q1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.toml",
"id = 2\nslug = \"q2\"\ntitle = \"Q2\"\nstatus = \"answered\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "QUE-001", "QUE-002")
.expect("supersession should proceed but not flip terminal status");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/question/002/record-002.toml"))
.unwrap();
assert!(
old_toml.contains("status = \"answered\""),
"OLD.status should remain answered (terminal), got: {old_toml}"
);
assert!(
!old_toml.contains("updated = \"2026-01-01\""),
"OLD.updated should be refreshed, got: {old_toml}"
);
}
#[test]
fn supersede_cross_family_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/001/adr-001.md", "body\n");
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.toml",
"id = 2\nslug = \"a2\"\ntitle = \"A2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.md",
"body\n",
);
let result = run_supersede(Some(root.to_path_buf()), "ADR-001", "ASM-002");
assert!(result.is_err(), "cross-family supersession should be refused");
let err = result.unwrap_err().to_string();
assert!(
err.contains("cross-family"),
"error should mention cross-family, got: {err}"
);
}
#[test]
fn supersede_self_supersession_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/001/adr-001.md", "body\n");
let result = run_supersede(Some(root.to_path_buf()), "ADR-001", "ASM-002");
assert!(
result.is_err(),
"cross-family should be caught earlier, but self-same should also refuse"
);
}
#[test]
fn supersede_self_supersession_refused_same_entity() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/001/adr-001.md", "body\n");
let result = run_supersede(Some(root.to_path_buf()), "ADR-001", "ADR-001");
assert!(
result.is_err(),
"self-supersession should be refused"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("cannot supersede itself"),
"error should mention self-supersession, got: {err}"
);
}
#[test]
fn supersede_already_terminal_no_flip() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.toml",
"id = 1\nslug = \"d1\"\ntitle = \"D1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"resolved\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "DEC-001", "DEC-002")
.expect("supersede on terminal OLD should succeed");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/decision/002/record-002.toml"))
.unwrap();
assert!(
old_toml.contains("status = \"resolved\""),
"OLD.status should stay resolved, got: {old_toml}"
);
}
#[test]
fn supersede_idempotent_cross_kind() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/context/001/record-001.toml",
"id = 1\nslug = \"c1\"\ntitle = \"C1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/context/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.toml",
"id = 2\nslug = \"q2\"\ntitle = \"Q2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "CON-001", "QUE-002")
.expect("first supersede should succeed");
run_supersede(Some(root.to_path_buf()), "CON-001", "QUE-002")
.expect("idempotent re-run should succeed");
}
#[test]
fn supersede_decision_to_question_reopening_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.toml",
"id = 1\nslug = \"q1\"\ntitle = \"Q1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"superseded\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = [\"DEC-003\"]\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
let result = run_supersede(Some(root.to_path_buf()), "QUE-001", "DEC-002");
assert!(
result.is_err(),
"reopening a superseded decision with a question should be refused"
);
}
#[test]
fn supersede_torn_recovery() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.toml",
"id = 1\nslug = \"d1\"\ntitle = \"D1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "DEC-001", "DEC-002")
.expect("first supersede should succeed");
let old_path = root.join(".doctrine/knowledge/decision/002/record-002.toml");
let mut old_toml = std::fs::read_to_string(&old_path).unwrap();
old_toml = old_toml.replace("superseded_by = [\"DEC-001\"]", "superseded_by = []");
std::fs::write(&old_path, &old_toml).unwrap();
let result = run_supersede(Some(root.to_path_buf()), "DEC-001", "DEC-002");
assert!(result.is_err(), "torn state with missing carveout should error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("empty/inconsistent"),
"error should mention inconsistent carveout, got: {err}"
);
}
}