Skip to main content

release_kit/plan/
apply.rs

1//! The apply: a stored plan executed exactly, or refused because its
2//! inputs moved.
3//!
4//! An apply that recomputed intent from the tree could act on something
5//! the operator never saw. So it reads the plan that was reviewed, asks
6//! one question first, is the world still the one the plan described,
7//! and refuses naming what moved when it is not. Readiness is enforced
8//! with no override: a gap is honest and is not permission. Every local
9//! operation is staged through the transaction in [`crate::atomic`] and
10//! renamed in order, with the record last, and the run lands in the
11//! journal `rk runs` reads.
12
13use std::collections::BTreeMap;
14
15use camino::Utf8Path;
16use serde::Serialize;
17
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::{self, manifest};
22use crate::self_depend::manager::{self, Manager, PinRead};
23use crate::setup::journal::Journal;
24
25use super::{Classification, Operation, Plan, Postcondition, Readiness, fingerprint};
26
27/// The version of the apply report's shape.
28pub const APPLY_SCHEMA: &str = "rk.reconcile-apply/1";
29
30/// One operation and what happened to it.
31#[derive(Debug, Clone, Serialize)]
32pub struct OperationResult {
33    /// The operation's kind word.
34    pub op: &'static str,
35    /// The path it touched, where it touched one.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub path: Option<String>,
38    /// `ok`.
39    pub status: &'static str,
40}
41
42/// One postcondition and what it found.
43#[derive(Debug, Clone, Serialize)]
44pub struct PostconditionResult {
45    /// The check, as the plan names it.
46    pub check: String,
47    /// `ok`, `failed`, or `reported` for the standing status check,
48    /// whose answer names what is short without failing the apply.
49    pub status: &'static str,
50    /// What was found, where the check failed.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub detail: Option<String>,
53}
54
55/// The machine form of an apply.
56#[derive(Debug, Serialize)]
57pub struct Applied {
58    /// The shape version of this document.
59    pub schema: &'static str,
60    /// The plan that was applied.
61    pub plan_id: String,
62    /// The fingerprint the apply revalidated against.
63    pub input_fingerprint: Digest,
64    /// The target written.
65    pub target: String,
66    /// The plan's classification.
67    pub classification: Classification,
68    /// Every operation, in the order it landed.
69    pub operations: Vec<OperationResult>,
70    /// Every postcondition, with its outcome.
71    pub postconditions: Vec<PostconditionResult>,
72    /// The journal entry, where the journal took one.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub run_id: Option<String>,
75    /// What plausibly follows.
76    pub next: Vec<String>,
77}
78
79impl Applied {
80    /// The postconditions that failed.
81    #[must_use]
82    pub fn failed(&self) -> Vec<&PostconditionResult> {
83        self.postconditions
84            .iter()
85            .filter(|result| result.status == "failed")
86            .collect()
87    }
88
89    /// The failure a caller returns after rendering the report: the
90    /// writes landed, and a check then found the target short of what
91    /// the plan promised.
92    #[must_use]
93    pub fn failure(&self) -> Option<RkError> {
94        let failed = self.failed();
95        if failed.is_empty() {
96            return None;
97        }
98        Some(RkError::check_failed(
99            Diagnostic::new(
100                Reason::PostconditionFailed,
101                format!(
102                    "the writes landed and {} postcondition{} failed: {}",
103                    failed.len(),
104                    if failed.len() == 1 { "" } else { "s" },
105                    failed
106                        .iter()
107                        .map(|result| {
108                            format!(
109                                "{} ({})",
110                                result.check,
111                                result.detail.as_deref().unwrap_or("no detail")
112                            )
113                        })
114                        .collect::<Vec<_>>()
115                        .join(", ")
116                ),
117            )
118            .expected("every postcondition the plan carries satisfied after the writes")
119            .action(format!(
120                "rk status --target {} names what is short",
121                self.target
122            ))
123            .target_state("written"),
124        ))
125    }
126}
127
128/// Refuse anything but a ready plan.
129///
130/// # Errors
131///
132/// A refusal with [`Reason::PlanNotReady`] naming the unresolved decision
133/// ids for a plan that needs a decision, or the failed required
134/// preconditions for a blocked one.
135pub fn gate(plan: &Plan) -> Result<(), RkError> {
136    match plan.readiness {
137        Readiness::Ready => Ok(()),
138        Readiness::NeedsDecision => {
139            let ids: Vec<String> = plan
140                .preconditions
141                .iter()
142                .filter(|p| !p.evaluation.holds())
143                .filter_map(|p| p.decision.clone())
144                .collect();
145            Err(RkError::refusal(
146                Diagnostic::new(
147                    Reason::PlanNotReady,
148                    format!(
149                        "plan {} waits on a decision, and nothing was written: {}",
150                        plan.identity.plan_id,
151                        ids.join(", ")
152                    ),
153                )
154                .expected("every decision the plan names selected")
155                .action("rk reconcile plan --decide <id>=<answer> selects an answer and stores a fresh plan")
156                .target_state("unchanged"),
157            ))
158        }
159        Readiness::Blocked => {
160            let failed: Vec<String> = plan
161                .preconditions
162                .iter()
163                .filter(|p| p.requirement == super::Requirement::Required && !p.evaluation.holds())
164                .map(|p| {
165                    let reason = match &p.evaluation {
166                        super::Evaluation::Satisfied => String::new(),
167                        super::Evaluation::NotObserved { reason }
168                        | super::Evaluation::Unsatisfied { reason } => reason.clone(),
169                    };
170                    format!("{} ({reason})", p.id)
171                })
172                .collect();
173            Err(RkError::refusal(
174                Diagnostic::new(
175                    Reason::PlanNotReady,
176                    format!(
177                        "plan {} is blocked, and nothing was written: {}",
178                        plan.identity.plan_id,
179                        failed.join(", ")
180                    ),
181                )
182                .expected("every required precondition satisfied")
183                .action("resolve each, then rk reconcile plan again")
184                .target_state("unchanged"),
185            ))
186        }
187    }
188}
189
190/// Refuse a stored plan whose semantic inputs no longer match a fresh
191/// computation over the same request.
192///
193/// # Errors
194///
195/// A refusal with [`Reason::StateDrift`] naming every field or
196/// destination whose canonical line changed, collected in one pass.
197pub fn revalidate(stored: &Plan, fresh: &Plan) -> Result<(), RkError> {
198    // The stored document is recomputed too, so a plan edited in the
199    // store after approval reads as moved rather than as approved.
200    let stored_now = fingerprint::compute(stored);
201    if stored.input_fingerprint == fresh.input_fingerprint && stored_now == stored.input_fingerprint
202    {
203        return Ok(());
204    }
205    let before = fingerprint::canonical(stored);
206    let after = fingerprint::canonical(fresh);
207    let before_lines: Vec<&str> = before.lines().collect();
208    let after_lines: Vec<&str> = after.lines().collect();
209    let mut moved: Vec<String> = Vec::new();
210    if stored_now != stored.input_fingerprint {
211        moved.push("the stored plan".to_owned());
212    }
213    for line in before_lines
214        .iter()
215        .filter(|line| !after_lines.contains(line))
216        .chain(
217            after_lines
218                .iter()
219                .filter(|line| !before_lines.contains(line)),
220        )
221    {
222        let label = label(line);
223        if !moved.contains(&label) {
224            moved.push(label);
225        }
226    }
227    Err(RkError::refusal(
228        Diagnostic::new(
229            Reason::StateDrift,
230            format!(
231                "plan {} no longer matches its inputs, and nothing was written: {}",
232                stored.identity.plan_id,
233                moved.join(", ")
234            ),
235        )
236        .expected("the target, the bundle, and the decisions as the plan observed them")
237        .action("rk reconcile plan computes a fresh plan over what is there now")
238        .target_state("unchanged"),
239    ))
240}
241
242/// The human label for one canonical line.
243fn label(line: &str) -> String {
244    let mut parts = line.split('\t');
245    match parts.next().unwrap_or_default() {
246        "candidate" => "the candidate bundle".to_owned(),
247        "record" => "the record".to_owned(),
248        "configuration" => "the configuration".to_owned(),
249        "operation" => {
250            let kind = parts.next().unwrap_or_default();
251            let subject = parts.next().unwrap_or_default();
252            format!("operation {kind} {subject}")
253        }
254        "precondition" => format!("precondition {}", parts.next().unwrap_or_default()),
255        "decision" => format!("decision {}", parts.next().unwrap_or_default()),
256        other => other.to_owned(),
257    }
258}
259
260/// Refuse where a destination no longer holds the digest an operation's
261/// `before` names, every mismatch collected in one pass.
262///
263/// # Errors
264///
265/// A refusal with [`Reason::StateDrift`] naming each destination, and
266/// [`RkError::Io`] for a read that fails.
267pub fn verify_before_digests(target: &Utf8Path, plan: &Plan) -> Result<(), RkError> {
268    let mut moved: Vec<String> = Vec::new();
269    let pin = crate::self_depend::observe(target).ok();
270    for operation in &plan.operations {
271        let (subject, held, wanted): (String, Option<String>, Option<String>) = match operation {
272            Operation::WriteFile { path, before, .. }
273            | Operation::SpliceBlock { path, before, .. } => (
274                path.clone(),
275                landing::read_recorded(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
276                before.as_ref().map(ToString::to_string),
277            ),
278            Operation::RemoveOwnedFile { path, before } => (
279                path.clone(),
280                read_optional(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
281                Some(before.to_string()),
282            ),
283            Operation::WriteRecord { before, .. } => (
284                manifest::MANIFEST_PATH.to_owned(),
285                read_optional(target, manifest::MANIFEST_PATH)?
286                    .map(|bytes| Digest::of(&bytes).to_string()),
287                before.as_ref().map(ToString::to_string),
288            ),
289            Operation::UpdatePin {
290                manager, before, ..
291            } => (
292                format!("the {manager} pin"),
293                pin.as_ref().and_then(|observed| {
294                    parse_manager(manager)
295                        .and_then(|m| observed.entry(m))
296                        .and_then(|entry| entry.version.clone())
297                }),
298                Some(before.clone()),
299            ),
300        };
301        if held != wanted {
302            moved.push(subject);
303        }
304    }
305    if moved.is_empty() {
306        return Ok(());
307    }
308    Err(RkError::refusal(
309        Diagnostic::new(
310            Reason::StateDrift,
311            format!(
312                "these destinations changed since the plan observed them, and nothing was written: {}",
313                moved.join(", ")
314            ),
315        )
316        .expected("every destination holding what the plan's before digest names")
317        .action("rk reconcile plan computes a fresh plan over what is there now")
318        .target_state("unchanged"),
319    ))
320}
321
322/// Execute one gated, revalidated plan: stage every operation, commit
323/// the renames in order, run the postconditions, and journal the run.
324///
325/// # Errors
326///
327/// The gate's and the revalidation's refusals, a refusal for a
328/// destination that moved, and [`RkError::Io`] for a staged write or a
329/// rename that fails, whose message names every destination that landed
330/// before it. A failed postcondition is not an error here: the report
331/// carries it, and [`Applied::failure`] is the error the caller returns
332/// after rendering.
333pub fn run(
334    target: &Utf8Path,
335    stored: &Plan,
336    blobs: &BTreeMap<Digest, Vec<u8>>,
337    fresh: &Plan,
338    mut journal: Option<Journal>,
339) -> Result<Applied, RkError> {
340    let outcome = execute(target, stored, blobs, fresh, journal.as_mut());
341    if let Some(journal) = journal.as_mut() {
342        match &outcome {
343            Ok(applied) => {
344                let failed = applied.failed();
345                if failed.is_empty() {
346                    journal.finish(0, None);
347                } else {
348                    journal.finish(1, Some(Reason::PostconditionFailed.as_str()));
349                }
350            }
351            Err(error) => {
352                journal.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
353            }
354        }
355    }
356    let run_id = journal.as_ref().map(|journal| journal.run_id().to_owned());
357    outcome.map(|mut applied| {
358        applied.run_id = run_id;
359        applied
360    })
361}
362
363fn execute(
364    target: &Utf8Path,
365    stored: &Plan,
366    blobs: &BTreeMap<Digest, Vec<u8>>,
367    fresh: &Plan,
368    mut journal: Option<&mut Journal>,
369) -> Result<Applied, RkError> {
370    gate(stored)?;
371    revalidate(stored, fresh)?;
372    verify_before_digests(target, stored)?;
373    if let Some(journal) = journal.as_deref_mut() {
374        journal.event_line(&format!(
375            r#"{{"event":"plan","plan_id":"{}","input_fingerprint":"{}","operations":{}}}"#,
376            stored.identity.plan_id,
377            stored.input_fingerprint,
378            stored.operations.len()
379        ));
380    }
381    let (txn, removals) = stage(target, stored, blobs)?;
382    // The interruption proof's seam: a rename stopped on purpose.
383    let stop = std::env::var_os("RK_APPLY_INTERRUPT_AT").map(std::path::PathBuf::from);
384    let landed = txn
385        .commit_stopping_at(stop.as_deref())
386        .map_err(|interrupted| {
387            if let Some(journal) = journal.as_deref_mut() {
388                for path in &interrupted.landed {
389                    journal.event_line(&format!(
390                        r#"{{"event":"operation","path":"{}","status":"ok"}}"#,
391                        path.display()
392                    ));
393                }
394                journal.event_line(&format!(
395                    r#"{{"event":"operation","path":"{}","status":"failed","detail":"{}"}}"#,
396                    interrupted.failed.display(),
397                    interrupted.error
398                ));
399            }
400            interrupted_error(&interrupted)
401        })?;
402    for path in &removals {
403        std::fs::remove_file(target.join(path))?;
404    }
405    debug_assert_eq!(landed.len(), txn_len(&stored.operations));
406    let operations: Vec<OperationResult> = stored
407        .operations
408        .iter()
409        .map(|operation| OperationResult {
410            op: operation.kind(),
411            path: match operation {
412                Operation::WriteRecord { .. } => Some(manifest::MANIFEST_PATH.to_owned()),
413                Operation::UpdatePin { manager, .. } => Some(format!("the {manager} pin")),
414                other => other.path().map(str::to_owned),
415            },
416            status: "ok",
417        })
418        .collect();
419    let postconditions = postconditions(target, stored);
420    if let Some(journal) = journal {
421        for result in &operations {
422            journal.event_line(&format!(
423                r#"{{"event":"operation","op":"{}","path":"{}","status":"{}"}}"#,
424                result.op,
425                result.path.as_deref().unwrap_or_default(),
426                result.status
427            ));
428        }
429        for result in &postconditions {
430            journal.event_line(&format!(
431                r#"{{"event":"postcondition","check":"{}","status":"{}"}}"#,
432                result.check, result.status
433            ));
434        }
435    }
436    Ok(Applied {
437        schema: APPLY_SCHEMA,
438        plan_id: stored.identity.plan_id.clone(),
439        input_fingerprint: stored.input_fingerprint.clone(),
440        target: target.to_string(),
441        classification: stored.classification,
442        operations,
443        postconditions,
444        run_id: None,
445        next: vec![
446            "commit the written files, the record included".to_owned(),
447            format!("rk status --target {target} reports the result"),
448        ],
449    })
450}
451
452/// Every write staged before any rename, so an unreadable destination
453/// or a missing blob surfaces while the target is still untouched. The
454/// removals come back beside the transaction, because a removal is not
455/// a rename and runs after the commit.
456fn stage(
457    target: &Utf8Path,
458    stored: &Plan,
459    blobs: &BTreeMap<Digest, Vec<u8>>,
460) -> Result<(crate::atomic::Transaction, Vec<String>), RkError> {
461    let mut txn = crate::atomic::Transaction::new();
462    let mut removals: Vec<String> = Vec::new();
463    let pin = stored
464        .operations
465        .iter()
466        .any(|operation| matches!(operation, Operation::UpdatePin { .. }))
467        .then(|| crate::self_depend::observe(target))
468        .transpose()?;
469    for operation in &stored.operations {
470        match operation {
471            Operation::WriteFile { path, after, .. } => {
472                txn.stage(target.join(path).as_std_path(), blob(blobs, after)?)?;
473            }
474            Operation::SpliceBlock { path, after, .. } => {
475                let existing = read_optional(target, path)?
476                    .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
477                let block = String::from_utf8_lossy(blob(blobs, after)?).into_owned();
478                let spliced = if path == landing::HOOKS_DESTINATION {
479                    landing::splice_hooks_block(existing.as_deref(), &block)
480                        .map_err(std::io::Error::other)?
481                } else {
482                    landing::splice_agents_block(existing.as_deref(), &block)
483                };
484                txn.stage(target.join(path).as_std_path(), spliced.as_bytes())?;
485            }
486            Operation::RemoveOwnedFile { path, .. } => removals.push(path.clone()),
487            Operation::WriteRecord { after, .. } => {
488                txn.stage(
489                    target.join(manifest::MANIFEST_PATH).as_std_path(),
490                    blob(blobs, after)?,
491                )?;
492            }
493            Operation::UpdatePin {
494                manager,
495                before,
496                after,
497            } => {
498                let (file, text) = pin_text(pin.as_ref(), manager, before)?;
499                let PinRead::One { line, .. } =
500                    manager::read_pin(parse_manager(manager).unwrap_or(Manager::Mise), &text)
501                else {
502                    return Err(pin_moved(manager));
503                };
504                let rewritten = manager::rewrite_line(&text, line, before, after);
505                txn.stage(target.join(&file).as_std_path(), rewritten.as_bytes())?;
506            }
507        }
508    }
509    Ok((txn, removals))
510}
511
512/// The failure of a commit that stopped part way, naming every
513/// destination that landed before it, under the I/O kind that stopped it.
514fn interrupted_error(interrupted: &crate::atomic::Interrupted) -> RkError {
515    RkError::Io(std::io::Error::new(
516        interrupted.error.kind(),
517        format!(
518            "the apply stopped at {}: {}; each destination holds its previous bytes or its new ones, and these landed before it: {}",
519            interrupted.failed.display(),
520            interrupted.error,
521            if interrupted.landed.is_empty() {
522                "none".to_owned()
523            } else {
524                interrupted
525                    .landed
526                    .iter()
527                    .map(|path| path.display().to_string())
528                    .collect::<Vec<_>>()
529                    .join(", ")
530            }
531        ),
532    ))
533}
534
535/// How many staged renames the operations produce.
536fn txn_len(operations: &[Operation]) -> usize {
537    operations
538        .iter()
539        .filter(|operation| !matches!(operation, Operation::RemoveOwnedFile { .. }))
540        .count()
541}
542
543/// Run every postcondition the plan carries and report each outcome.
544#[must_use]
545pub fn postconditions(target: &Utf8Path, plan: &Plan) -> Vec<PostconditionResult> {
546    let pin = plan
547        .postconditions
548        .iter()
549        .any(|check| matches!(check, Postcondition::PinReads { .. }))
550        .then(|| crate::self_depend::observe(target).ok())
551        .flatten();
552    plan.postconditions
553        .iter()
554        .map(|check| {
555            let (name, outcome): (String, Result<(), String>) = match check {
556                Postcondition::RecordReadsBack { sha256 } => (
557                    "record-reads-back".to_owned(),
558                    holds(read_optional(target, manifest::MANIFEST_PATH), sha256),
559                ),
560                Postcondition::DestinationHolds { path, sha256 } => (
561                    format!("destination-holds:{path}"),
562                    holds(
563                        landing::read_recorded(target, path).map_err(RkError::Io),
564                        sha256,
565                    ),
566                ),
567                Postcondition::StatusCheckClean => {
568                    ("status-check-clean".to_owned(), status_check(target))
569                }
570                Postcondition::PinReads { manager, version } => (
571                    format!("pin-reads:{manager}"),
572                    match pin
573                        .as_ref()
574                        .and_then(|observed| parse_manager(manager).and_then(|m| observed.entry(m)))
575                        .and_then(|entry| entry.version.clone())
576                    {
577                        Some(found) if &found == version => Ok(()),
578                        Some(found) => Err(format!("the {manager} pin reads {found}")),
579                        None => Err(format!("no {manager} pin reads")),
580                    },
581                ),
582            };
583            // The status check judges the whole target, sentinels the
584            // operator still owes included, so its answer is reported and
585            // never fails an apply whose own writes landed as promised.
586            let advisory = matches!(check, Postcondition::StatusCheckClean);
587            match outcome {
588                Ok(()) => PostconditionResult {
589                    check: name,
590                    status: "ok",
591                    detail: None,
592                },
593                Err(detail) => PostconditionResult {
594                    check: name,
595                    status: if advisory { "reported" } else { "failed" },
596                    detail: Some(detail),
597                },
598            }
599        })
600        .collect()
601}
602
603/// Whether the bytes read digest to what the plan promised.
604fn holds(read: Result<Option<Vec<u8>>, RkError>, wanted: &Digest) -> Result<(), String> {
605    match read {
606        Ok(Some(bytes)) if Digest::of(&bytes) == *wanted => Ok(()),
607        Ok(Some(bytes)) => Err(format!("holds {}", Digest::of(&bytes))),
608        Ok(None) => Err("absent".to_owned()),
609        Err(error) => Err(error.to_string()),
610    }
611}
612
613/// `rk status --check` as the standing postcondition: this binary run
614/// against the target, judged by its exit code.
615fn status_check(target: &Utf8Path) -> Result<(), String> {
616    let exe = std::env::current_exe().map_err(|error| format!("no engine path: {error}"))?;
617    let mut command = std::process::Command::new(exe);
618    for var in crate::maintenance::GIT_HOOK_VARS {
619        command.env_remove(var);
620    }
621    let out = command
622        .args(["status", "--check", "--target"])
623        .arg(target)
624        .output()
625        .map_err(|error| format!("rk status --check could not run: {error}"))?;
626    if out.status.success() {
627        return Ok(());
628    }
629    let stderr = String::from_utf8_lossy(&out.stderr);
630    Err(stderr
631        .lines()
632        .find(|line| !line.trim().is_empty())
633        .unwrap_or("rk status --check exited nonzero")
634        .trim()
635        .to_owned())
636}
637
638fn blob<'a>(blobs: &'a BTreeMap<Digest, Vec<u8>>, digest: &Digest) -> Result<&'a [u8], RkError> {
639    blobs.get(digest).map(Vec::as_slice).ok_or_else(|| {
640        RkError::Other(anyhow::anyhow!(
641            "the plan names digest {digest} and its store holds no such blob"
642        ))
643    })
644}
645
646fn read_optional(target: &Utf8Path, rel: &str) -> Result<Option<Vec<u8>>, RkError> {
647    match std::fs::read(target.join(rel)) {
648        Ok(bytes) => Ok(Some(bytes)),
649        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
650        Err(error) => Err(RkError::Io(error)),
651    }
652}
653
654fn parse_manager(name: &str) -> Option<Manager> {
655    Manager::ALL.into_iter().find(|m| m.as_str() == name)
656}
657
658/// The wired manager's file and text, for the one-fact rewrite the
659/// apply stages; the flake pair is never moved here.
660fn pin_text(
661    observed: Option<&crate::self_depend::Observed>,
662    manager: &str,
663    before: &str,
664) -> Result<(String, String), RkError> {
665    let parsed = parse_manager(manager).ok_or_else(|| pin_moved(manager))?;
666    if parsed == Manager::Flake {
667        return Err(RkError::refusal(
668            Diagnostic::new(
669                Reason::PrerequisiteUnmet,
670                "the flake pin moves through the self-depend sync verb under --apply, with nix and the network; an offline apply cannot stage it",
671            )
672            .expected("a one-fact manager pin, or the sync verb for the flake pair")
673            .target_state("unchanged"),
674        ));
675    }
676    let entry = observed
677        .and_then(|observed| observed.entry(parsed))
678        .filter(|entry| entry.version.as_deref() == Some(before))
679        .ok_or_else(|| pin_moved(manager))?;
680    match (&entry.file, &entry.text) {
681        (Some(file), Some(text)) => Ok((file.clone(), text.clone())),
682        _ => Err(pin_moved(manager)),
683    }
684}
685
686fn pin_moved(manager: &str) -> RkError {
687    RkError::refusal(
688        Diagnostic::new(
689            Reason::StateDrift,
690            format!(
691                "the {manager} pin no longer reads as the plan observed it, and nothing was written"
692            ),
693        )
694        .expected("the manager file as the plan observed it")
695        .action("rk reconcile plan computes a fresh plan over what is there now")
696        .target_state("unchanged"),
697    )
698}
699
700#[cfg(test)]
701mod tests {
702    use camino::Utf8PathBuf;
703
704    use super::{PostconditionResult, postconditions};
705    use crate::digest::Digest;
706    use crate::plan::{Plan, Postcondition};
707
708    /// A plan whose postconditions run against a scratch target.
709    fn plan_with(checks: &[Postcondition]) -> Plan {
710        let json = serde_json::json!({
711            "schema": crate::plan::PLAN_SCHEMA,
712            "identity": {"plan_id": "0123456789abcdef", "created_at": "2026-01-01T00:00:00Z", "engine_version": "0.0.0"},
713            "classification": "setup",
714            "findings": [],
715            "desired_state": {"intent": "reconcile", "selector": "embedded", "release": {"version": "0.0.0", "venue": "embedded", "payload_sha256": Digest::of(b"a").to_string(), "payload_schema": 1}},
716            "observed_state": {
717                "repository": {"target": "/tmp/t", "git": false, "tags": 0, "long_lived_branches": [], "release_markers": [], "collisions": [], "verdict": "greenfield", "evidence_refs": []},
718                "installation": {"record": {"state": "absent"}, "configuration": {"present": false, "pending": []}, "destinations": [], "evidence_refs": []},
719                "host": {"engine_version": "0.0.0", "evidence_refs": []},
720                "forge": {"state": "not-observed", "reason": "not requested"}
721            },
722            "release": {"candidate": {"version": "0.0.0", "payload_sha256": Digest::of(b"a").to_string(), "payload_schema": 1, "artifacts": 0, "evidence_refs": []}, "verification": {"method": "embedded"}, "baseline": {"state": "not-needed"}, "compatibility": {"engine_schema": 1, "bundle_schema": 1, "readable": true}, "guidance": {"coverage": "not-shipped"}},
723            "operations": [],
724            "preconditions": [],
725            "decisions": [],
726            "postconditions": checks,
727            "evidence": [],
728            "readiness": "ready",
729            "input_fingerprint": Digest::of(b"a").to_string()
730        });
731        serde_json::from_value(json).expect("a plan deserializes")
732    }
733
734    /// Every postcondition runs and reports; a destination holding other
735    /// bytes than promised is reported failed with what it holds, and
736    /// the check beside it still reports ok.
737    #[test]
738    fn postconditions_run_and_a_failure_is_reported() {
739        let dir = tempfile::tempdir().expect("a scratch target exists");
740        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
741        std::fs::write(target.join("SECURITY.md"), b"held").expect("writes");
742        let plan = plan_with(&[
743            Postcondition::DestinationHolds {
744                path: "SECURITY.md".into(),
745                sha256: Digest::of(b"held"),
746            },
747            Postcondition::DestinationHolds {
748                path: "SECURITY.md".into(),
749                sha256: Digest::of(b"promised"),
750            },
751            Postcondition::RecordReadsBack {
752                sha256: Digest::of(b"record"),
753            },
754        ]);
755        let results: Vec<PostconditionResult> = postconditions(&target, &plan);
756        assert_eq!(results.len(), 3);
757        assert_eq!(results[0].status, "ok");
758        assert_eq!(results[1].status, "failed");
759        assert_eq!(
760            results[1].detail.as_deref(),
761            Some(format!("holds {}", Digest::of(b"held")).as_str())
762        );
763        assert_eq!(results[2].status, "failed");
764        assert_eq!(results[2].detail.as_deref(), Some("absent"));
765        let applied = super::Applied {
766            schema: super::APPLY_SCHEMA,
767            plan_id: "0123456789abcdef".into(),
768            input_fingerprint: Digest::of(b"a"),
769            target: target.to_string(),
770            classification: crate::plan::Classification::Setup,
771            operations: vec![],
772            postconditions: results,
773            run_id: None,
774            next: vec![],
775        };
776        let failure = applied
777            .failure()
778            .expect("a failed postcondition is a failure");
779        assert_eq!(failure.exit_code(), 1);
780        assert_eq!(
781            failure.reason(),
782            crate::diagnostic::Reason::PostconditionFailed
783        );
784    }
785
786    /// The apply report's shape, held by snapshot.
787    #[test]
788    fn the_apply_report_schema_snapshot_holds() {
789        let applied = super::Applied {
790            schema: super::APPLY_SCHEMA,
791            plan_id: "0123456789abcdef".into(),
792            input_fingerprint: Digest::of(b"a"),
793            target: "/tmp/t".into(),
794            classification: crate::plan::Classification::Upgrade,
795            operations: vec![super::OperationResult {
796                op: "write-record",
797                path: Some(".release-kit/manifest.json".into()),
798                status: "ok",
799            }],
800            postconditions: vec![PostconditionResult {
801                check: "record-reads-back".into(),
802                status: "failed",
803                detail: Some("absent".into()),
804            }],
805            run_id: Some("run".into()),
806            next: vec!["commit the written files, the record included".into()],
807        };
808        assert_eq!(
809            serde_json::to_string(&applied).expect("serializes"),
810            format!(
811                r#"{{"schema":"rk.reconcile-apply/1","plan_id":"0123456789abcdef","input_fingerprint":"{}","target":"/tmp/t","classification":"upgrade","operations":[{{"op":"write-record","path":".release-kit/manifest.json","status":"ok"}}],"postconditions":[{{"check":"record-reads-back","status":"failed","detail":"absent"}}],"run_id":"run","next":["commit the written files, the record included"]}}"#,
812                Digest::of(b"a")
813            )
814        );
815    }
816}