cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! DR-side I/O wrapper and cascade lowering around the pure
//! [`DecisionRecordEdit`] vocabulary (defined in
//! `domain/model/decision_record/edit.rs`).
//!
//! - [`commit_dr_edits`] — loads each touched record, hands the batch
//!   to the model's [`apply_edits`], saves only when the record
//!   changed. First-seen-id ordering — atomicity contract preserved.
//! - [`lower_transition`] — lowers a `proposed → accepted` (or other
//!   legal transition) into the full edit list, including cascades.
//!   Not pure: reads cascade targets via the repo so the descendant
//!   edits are decided ahead of application. Contract: "lowering =
//!   read + plan, engine = write + apply".

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;

/// Commit a batch of edits to storage. Touched records are loaded
/// once each, [`apply_edits`] folds the whole batch onto each one
/// (mismatched edits are silently skipped at the model layer), and a
/// save fires only when the record changed.
///
/// Records absent from the repository are silently skipped (preserves
/// the current cartu-check-as-safety-net contract for dangling cascade
/// targets).
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(())
}

/// Lower a `transition_to` call (status transition + cascade) into a
/// flat `Vec<DecisionRecordEdit>`. Reads the repo to inspect cascade
/// targets so the descendant edits are decided ahead of application
/// (only-flip-if-non-terminal, don't-duplicate-back-link).
///
/// Cascades are emitted **before** the primary so that the engine's
/// first-seen-id order writes them first — preserving the current
/// atomicity contract.
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();

    // Cascades first (atomicity).
    for cascade in &outcome.cascades {
        let Ok(Some(descendant)) = repo.find_by_id(&cascade.target) else {
            // Dangling target — silently skip (cartu check safety net).
            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,
                    },
                },
            });
        }
    }

    // Primary last.
    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)
    }

    /// First spike test: a `proposed → accepted` with one `supersedes`
    /// link emits cascade edits before primary edits, applies cleanly,
    /// and reproduces the saves the legacy use case would produce.
    #[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");

        // Cascade emitted first: AddLink + SetStatus + AppendEvent on ADR-0001.
        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"
        ));
        // Primary last: SetStatus + AppendEvent on ADR-0002.
        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);
    }

    /// Cascade idempotence: descendant already terminal + back-linked,
    /// the lowering still emits a cascade `AddLink`/`SetStatus` only
    /// when needed. With both pre-conditions in place, no descendant
    /// edits are emitted, so no descendant save.
    #[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();

        // Only primary edits — no cascade edits for an already-settled descendant.
        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());
    }

    /// Dangling cascade target: lowering reads the repo, finds the
    /// descendant missing, silently skips its edits. Primary edits
    /// still emitted and applied.
    #[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();

        // Only primary edits — descendant is dangling.
        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())
        );
    }

    /// `amends`-only cascade: back-link is added, but the descendant's
    /// status stays untouched (cascade rule for amends per
    /// DDR-018QWJVHRH35B).
    #[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"));
        // No status-change event on the target.
        assert_eq!(saved_target.events.len(), 0);
    }

    /// Illegal transition: lowering returns Err, no edits, no saves.
    #[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());
    }

    /// Union-sourced records are read-only: any edit batch targeting
    /// them must be refused before any save fires.
    #[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());
    }
}