cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Decision-record lifecycle transition — the single oracle for both direct
//! user transitions and the cascade.
//!
//! `DecisionRecord::transition_to` validates the transition through the
//! typed [`DrStatus`] enum, builds the `StatusChanged` event, and emits the
//! list of cascade requests so the use-case shell can enact them through
//! the same model methods (DDR-018QWJVHRH35B).

use crate::domain::model::event::{Event, EventAction, State};
use crate::domain::model::record_ref::DecisionRecordRef;
use crate::domain::model::temporal::timestamp::Timestamp;

use super::{DecisionRecord, DrStatus, Relationship};

/// What kind of cascade effect a `proposed → accepted` transition triggers
/// on a linked target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CascadeAction {
    /// Target gets a `superseded-by` back-pointer and (if not already
    /// terminal) flips to `superseded`.
    Supersede { source: DecisionRecordRef },
    /// Target gets an `amended-by` back-pointer; status untouched.
    AmendedBy { source: DecisionRecordRef },
}

/// One cascade effect to apply to a target record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CascadeRequest {
    pub target: DecisionRecordRef,
    pub action: CascadeAction,
    pub timestamp: Timestamp,
}

/// Result of a successful `transition_to` call: the updated source record
/// (status + event log) plus the cascade requests the use case must apply.
#[derive(Debug, Clone)]
pub struct TransitionOutcome {
    pub from: DrStatus,
    pub to: DrStatus,
    pub updated: DecisionRecord,
    pub cascades: Vec<CascadeRequest>,
}

/// Why a transition was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransitionError {
    /// `from` is terminal — no outgoing transitions are allowed.
    FromTerminal { from: DrStatus },
    /// `from → to` is not a legal direct transition.
    Illegal {
        from: DrStatus,
        to: DrStatus,
        allowed: &'static [DrStatus],
    },
}

impl std::fmt::Display for TransitionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TransitionError::FromTerminal { from } => write!(
                f,
                "illegal transition from '{from}': (terminal — no further transitions)"
            ),
            TransitionError::Illegal { from, to, allowed } => {
                let allowed_list = allowed
                    .iter()
                    .map(|s| s.as_str())
                    .collect::<Vec<_>>()
                    .join(", ");
                write!(
                    f,
                    "illegal transition '{from}' → '{to}': allowed from '{from}' is {allowed_list}"
                )
            }
        }
    }
}

impl std::error::Error for TransitionError {}

impl DecisionRecord {
    /// Validate and apply a status transition. Returns the updated record
    /// (status + appended event) plus any cascades the use case must
    /// enact through `apply_cascade`.
    ///
    /// The cascade list is non-empty only on `proposed → accepted` and
    /// only when the source carries `supersedes` / `amends` outgoing
    /// links (DDR-018QWJVHRH35B § cascade).
    pub fn transition_to(
        &self,
        target: DrStatus,
        timestamp: Timestamp,
    ) -> Result<TransitionOutcome, TransitionError> {
        let current = self.status;

        if current == target {
            // The use case treats same-status as NoOp; the model expresses
            // it as Illegal because no transition is recorded. Callers
            // pre-check name equality before calling.
            return Err(TransitionError::Illegal {
                from: current,
                to: target,
                allowed: current.allowed_next(),
            });
        }

        if current.is_terminal() {
            return Err(TransitionError::FromTerminal { from: current });
        }

        if !current.allows(target) {
            return Err(TransitionError::Illegal {
                from: current,
                to: target,
                allowed: current.allowed_next(),
            });
        }

        let event = Event {
            timestamp: timestamp.clone(),
            action: EventAction::StatusChanged {
                from: State::new(current.as_str()).expect("DrStatus names are valid State"),
                to: State::new(target.as_str()).expect("DrStatus names are valid State"),
            },
        };
        let new_events = self.events.with_appended(event);
        let updated = self.clone().with_status(target).with_events(new_events);

        let cascades = if current == DrStatus::Proposed && target == DrStatus::Accepted {
            self.links
                .iter()
                .filter_map(|l| {
                    let action = match l.relationship {
                        Relationship::Supersedes => CascadeAction::Supersede {
                            source: self.id.clone(),
                        },
                        Relationship::Amends => CascadeAction::AmendedBy {
                            source: self.id.clone(),
                        },
                        _ => return None,
                    };
                    Some(CascadeRequest {
                        target: l.target.clone(),
                        action,
                        timestamp: timestamp.clone(),
                    })
                })
                .collect()
        } else {
            Vec::new()
        };

        Ok(TransitionOutcome {
            from: current,
            to: target,
            updated,
            cascades,
        })
    }
}

#[cfg(test)]
pub mod strategy {
    use super::{CascadeAction, CascadeRequest};
    use crate::domain::model::record_ref::strategy::decision_record_ref;
    use crate::domain::model::temporal::timestamp::Timestamp;
    use proptest::prelude::*;

    pub fn cascade_action() -> impl Strategy<Value = CascadeAction> {
        prop_oneof![
            decision_record_ref().prop_map(|source| CascadeAction::Supersede { source }),
            decision_record_ref().prop_map(|source| CascadeAction::AmendedBy { source }),
        ]
    }

    prop_compose! {
        pub fn cascade_request()(
            target in decision_record_ref(),
            action in cascade_action(),
        ) -> CascadeRequest {
            CascadeRequest {
                target,
                action,
                timestamp: Timestamp::new("2026-01-01T00:00:00Z").unwrap(),
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::decision_record::{RecordLink, RecordLinks};
    use crate::domain::model::record_ref::DecisionRecordRef;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn cascade_request_target_matches_action_source_when_supersede(r in strategy::cascade_request()) {
            // The strategy yields well-formed requests; check the action
            // is one of the two declared variants.
            let ok = match r.action {
                CascadeAction::Supersede { .. } | CascadeAction::AmendedBy { .. } => true,
            };
            prop_assert!(ok);
        }
    }

    fn ts() -> Timestamp {
        Timestamp::new("2026-05-08T00:00:00Z").unwrap()
    }

    fn dr_ref(s: &str) -> DecisionRecordRef {
        DecisionRecordRef::new(s).unwrap()
    }

    fn record(status: DrStatus) -> DecisionRecord {
        use crate::domain::model::body::Body;
        use crate::domain::model::entry_locator::EntryLocator;
        use crate::domain::model::entry_origin::EntryOrigin;
        use crate::domain::model::record_kind::RecordKind;
        use crate::domain::model::tag_list::TagList;
        use crate::domain::model::temporal::iso_date::IsoDate;
        use crate::domain::model::title::Title;
        DecisionRecord {
            id: dr_ref("ADR-0001"),
            kind: RecordKind::new("adr").unwrap(),
            title: Title::new("Use Rust").unwrap(),
            description: None,
            status,
            date: IsoDate::new("2026-05-01").unwrap(),
            tags: TagList::new(),
            aliases: Vec::new(),
            content: Body::default(),
            events: crate::domain::model::event::EventLog::new(),
            links: RecordLinks::new(),
            relates: crate::domain::model::relates::Relates::default(),
            origin: EntryOrigin::Local,
            location: EntryLocator::default(),
        }
    }

    #[test]
    fn legal_transition_returns_updated_record_and_event() {
        let r = record(DrStatus::Proposed);
        let outcome = r.transition_to(DrStatus::Accepted, ts()).unwrap();
        assert_eq!(outcome.from, DrStatus::Proposed);
        assert_eq!(outcome.to, DrStatus::Accepted);
        assert_eq!(outcome.updated.status.as_str(), "accepted");
        assert_eq!(outcome.updated.events.len(), 1);
        assert!(outcome.cascades.is_empty());
    }

    #[test]
    fn illegal_transition_is_rejected() {
        let r = record(DrStatus::Proposed);
        let err = r.transition_to(DrStatus::Deprecated, ts()).unwrap_err();
        assert!(matches!(err, TransitionError::Illegal { .. }));
    }

    #[test]
    fn transition_from_terminal_is_rejected() {
        let r = record(DrStatus::Rejected);
        let err = r.transition_to(DrStatus::Accepted, ts()).unwrap_err();
        assert!(matches!(err, TransitionError::FromTerminal { .. }));
    }

    #[test]
    fn proposed_to_accepted_emits_supersede_cascade_for_supersedes_links() {
        let r = record(DrStatus::Proposed).with_links({
            let mut l = RecordLinks::new();
            l.push(RecordLink {
                target: dr_ref("ADR-0099"),
                relationship: Relationship::Supersedes,
            });
            l
        });
        let outcome = r.transition_to(DrStatus::Accepted, ts()).unwrap();
        assert_eq!(outcome.cascades.len(), 1);
        assert!(matches!(
            outcome.cascades[0].action,
            CascadeAction::Supersede { .. }
        ));
        assert_eq!(outcome.cascades[0].target.as_str(), "ADR-0099");
    }

    #[test]
    fn proposed_to_accepted_emits_amended_by_for_amends_links() {
        let r = record(DrStatus::Proposed).with_links({
            let mut l = RecordLinks::new();
            l.push(RecordLink {
                target: dr_ref("ADR-0099"),
                relationship: Relationship::Amends,
            });
            l
        });
        let outcome = r.transition_to(DrStatus::Accepted, ts()).unwrap();
        assert_eq!(outcome.cascades.len(), 1);
        assert!(matches!(
            outcome.cascades[0].action,
            CascadeAction::AmendedBy { .. }
        ));
    }

    #[test]
    fn cascade_does_not_fire_outside_proposed_to_accepted() {
        let r = record(DrStatus::Accepted).with_links({
            let mut l = RecordLinks::new();
            l.push(RecordLink {
                target: dr_ref("ADR-0099"),
                relationship: Relationship::Supersedes,
            });
            l
        });
        let outcome = r.transition_to(DrStatus::Deprecated, ts()).unwrap();
        assert!(outcome.cascades.is_empty());
    }
}