use crate::domain::model::decision_record::{
apply_edits, CascadeAction, DecisionRecord, DecisionRecordEdit, DrStatus, RecordLink,
Relationship, TransitionError,
};
use crate::domain::model::event::{Event, EventAction, State};
use crate::domain::model::record_ref::DecisionRecordRef;
use crate::domain::model::temporal::timestamp::Timestamp;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
pub fn commit_dr_edits(
repo: &dyn DecisionRecordRepository,
edits: Vec<DecisionRecordEdit>,
) -> anyhow::Result<()> {
let mut order: Vec<DecisionRecordRef> = Vec::new();
let mut seen: std::collections::HashSet<DecisionRecordRef> = std::collections::HashSet::new();
for edit in &edits {
let id = edit.target_id().clone();
if seen.insert(id.clone()) {
order.push(id);
}
}
for id in order {
let Some(loaded) = repo.find_by_id(&id)? else {
continue;
};
if let crate::domain::model::entry_origin::EntryOrigin::Union { name } = &loaded.origin {
anyhow::bail!("cannot modify '{id}' — it is sourced from the read-only union '{name}'");
}
let after = apply_edits(loaded.clone(), edits.clone());
if after != loaded {
repo.save(&after)?;
}
}
Ok(())
}
pub fn lower_transition(
repo: &dyn DecisionRecordRepository,
record: &DecisionRecord,
target: DrStatus,
timestamp: Timestamp,
) -> Result<Vec<DecisionRecordEdit>, TransitionError> {
let outcome = record.transition_to(target, timestamp.clone())?;
let mut edits: Vec<DecisionRecordEdit> = Vec::new();
for cascade in &outcome.cascades {
let Ok(Some(descendant)) = repo.find_by_id(&cascade.target) else {
continue;
};
let (source, back_rel, flip_to_superseded) = match &cascade.action {
CascadeAction::Supersede { source } => {
(source.clone(), Relationship::SupersededBy, true)
}
CascadeAction::AmendedBy { source } => (source.clone(), Relationship::AmendedBy, false),
};
let already_back = descendant
.links
.iter()
.any(|l| l.relationship == back_rel && l.target == source);
if !already_back {
edits.push(DecisionRecordEdit::AddLink {
record: descendant.id.clone(),
link: RecordLink {
target: source,
relationship: back_rel,
},
});
}
if flip_to_superseded && !descendant.status.is_terminal() {
let from_state =
State::new(descendant.status.as_str()).expect("DrStatus name is valid State");
let to_state =
State::new(DrStatus::Superseded.as_str()).expect("DrStatus name is valid State");
edits.push(DecisionRecordEdit::SetStatus {
record: descendant.id.clone(),
status: DrStatus::Superseded,
});
edits.push(DecisionRecordEdit::AppendEvent {
record: descendant.id.clone(),
event: Event {
timestamp: cascade.timestamp.clone(),
action: EventAction::StatusChanged {
from: from_state,
to: to_state,
},
},
});
}
}
let from_state = State::new(outcome.from.as_str()).expect("DrStatus name is valid State");
let to_state = State::new(outcome.to.as_str()).expect("DrStatus name is valid State");
edits.push(DecisionRecordEdit::SetStatus {
record: record.id.clone(),
status: outcome.to,
});
edits.push(DecisionRecordEdit::AppendEvent {
record: record.id.clone(),
event: Event {
timestamp,
action: EventAction::StatusChanged {
from: from_state,
to: to_state,
},
},
});
Ok(edits)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::usecases::decision_record::tests::{
adr, FakeDecisionRecordRepository, RecordFixture,
};
fn ts() -> Timestamp {
Timestamp::new("2026-05-08T00:00:00Z").unwrap()
}
fn dr_ref(s: &str) -> DecisionRecordRef {
DecisionRecordRef::new(s).unwrap()
}
fn build(fixture: RecordFixture) -> DecisionRecord {
let raw = fixture.id.as_deref().expect("with_id required").to_string();
let numeric = DecisionRecordRef::new(&raw).unwrap();
fixture.build(numeric)
}
#[test]
fn proposed_to_accepted_with_supersede_lowers_and_applies() {
let target = build(adr("Old design").with_id("ADR-0001").status("accepted"));
let source = build(
adr("New design")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "supersedes"),
);
let repo = FakeDecisionRecordRepository::with_records(vec![target, source.clone()]);
let edits = lower_transition(&repo, &source, DrStatus::Accepted, ts())
.expect("transition should be legal");
assert!(matches!(
edits[0],
DecisionRecordEdit::AddLink { ref record, .. } if record.as_str() == "ADR-0001"
));
assert!(matches!(
edits[1],
DecisionRecordEdit::SetStatus { ref record, status: DrStatus::Superseded } if record.as_str() == "ADR-0001"
));
assert!(matches!(
edits[2],
DecisionRecordEdit::AppendEvent { ref record, .. } if record.as_str() == "ADR-0001"
));
assert!(matches!(
edits[3],
DecisionRecordEdit::SetStatus { ref record, status: DrStatus::Accepted } if record.as_str() == "ADR-0002"
));
assert!(matches!(
edits[4],
DecisionRecordEdit::AppendEvent { ref record, .. } if record.as_str() == "ADR-0002"
));
commit_dr_edits(&repo, edits).expect("apply should succeed");
let saved_target = repo
.saved_with_id(&dr_ref("ADR-0001"))
.expect("target should be saved");
assert_eq!(saved_target.status.as_str(), "superseded");
assert!(saved_target.links.iter().any(
|l| l.relationship == Relationship::SupersededBy && l.target.as_str() == "ADR-0002"
));
assert_eq!(saved_target.events.len(), 1);
let saved_source = repo
.saved_with_id(&dr_ref("ADR-0002"))
.expect("source should be saved");
assert_eq!(saved_source.status.as_str(), "accepted");
assert_eq!(saved_source.events.len(), 1);
}
#[test]
fn cascade_is_noop_when_descendant_already_superseded_and_back_linked() {
let target = build(
adr("Old design")
.with_id("ADR-0001")
.status("superseded")
.with_link("ADR-0002", "superseded-by"),
);
let source = build(
adr("New design")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "supersedes"),
);
let repo = FakeDecisionRecordRepository::with_records(vec![target, source.clone()]);
let edits = lower_transition(&repo, &source, DrStatus::Accepted, ts()).unwrap();
assert_eq!(
edits.len(),
2,
"expected only primary edits, got {edits:#?}"
);
assert!(
matches!(edits[0], DecisionRecordEdit::SetStatus { ref record, .. } if record.as_str() == "ADR-0002")
);
assert!(
matches!(edits[1], DecisionRecordEdit::AppendEvent { ref record, .. } if record.as_str() == "ADR-0002")
);
commit_dr_edits(&repo, edits).unwrap();
assert!(repo.saved_with_id(&dr_ref("ADR-0001")).is_none());
assert!(repo.saved_with_id(&dr_ref("ADR-0002")).is_some());
}
#[test]
fn cascade_silently_skips_missing_descendant() {
let source = build(
adr("New design")
.with_id("ADR-0001")
.status("proposed")
.with_link("ADR-0099", "supersedes"),
);
let repo = FakeDecisionRecordRepository::with_records(vec![source.clone()]);
let edits = lower_transition(&repo, &source, DrStatus::Accepted, ts()).unwrap();
assert_eq!(edits.len(), 2);
commit_dr_edits(&repo, edits).unwrap();
assert_eq!(
repo.saved_with_id(&dr_ref("ADR-0001"))
.map(|r| r.status.as_str().to_string()),
Some("accepted".to_string())
);
}
#[test]
fn amends_cascade_adds_back_link_but_no_status_flip() {
let target = build(adr("Original").with_id("ADR-0001").status("accepted"));
let source = build(
adr("Amendment")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "amends"),
);
let repo = FakeDecisionRecordRepository::with_records(vec![target, source.clone()]);
let edits = lower_transition(&repo, &source, DrStatus::Accepted, ts()).unwrap();
commit_dr_edits(&repo, edits).unwrap();
let saved_target = repo.saved_with_id(&dr_ref("ADR-0001")).unwrap();
assert_eq!(saved_target.status.as_str(), "accepted");
assert!(saved_target
.links
.iter()
.any(|l| l.relationship == Relationship::AmendedBy && l.target.as_str() == "ADR-0002"));
assert_eq!(saved_target.events.len(), 0);
}
#[test]
fn illegal_transition_returns_error_and_emits_no_edits() {
let source = build(adr("Foo").with_id("ADR-0001").status("proposed"));
let repo = FakeDecisionRecordRepository::with_records(vec![source.clone()]);
let err = lower_transition(&repo, &source, DrStatus::Deprecated, ts());
assert!(matches!(err, Err(TransitionError::Illegal { .. })));
assert!(repo.saved_with_id(&dr_ref("ADR-0001")).is_none());
}
#[test]
fn commit_refuses_to_modify_a_union_record() {
use crate::domain::model::record_ref::DecisionRecordRef;
let target = adr("Shared design")
.with_id("ADR-0009")
.status("accepted")
.build(DecisionRecordRef::new("ADR-0009").unwrap());
let mut repo = FakeDecisionRecordRepository::new();
repo.push_union_record(target, "../shared/adr");
let edits = vec![DecisionRecordEdit::SetStatus {
record: DecisionRecordRef::new("ADR-0009").unwrap(),
status: DrStatus::Deprecated,
}];
let err = commit_dr_edits(&repo, edits).unwrap_err().to_string();
assert!(err.contains("ADR-0009"), "got: {err}");
assert!(err.contains("../shared/adr"), "got: {err}");
assert!(repo.last_saved().is_none());
}
}