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 where the world the apply runs against is no longer ready,
191/// even though the approved plan was.
192///
193/// # Errors
194///
195/// A refusal with [`Reason::PlanNotReady`] naming what the freshly
196/// computed plan now waits on.
197pub fn gate_fresh(fresh: &Plan) -> Result<(), RkError> {
198    if fresh.readiness == Readiness::Ready {
199        return Ok(());
200    }
201    let ids: Vec<String> = fresh
202        .preconditions
203        .iter()
204        .filter(|p| !p.evaluation.holds())
205        .map(|p| p.id.clone())
206        .collect();
207    Err(RkError::refusal(
208        Diagnostic::new(
209            Reason::PlanNotReady,
210            format!(
211                "the target is no longer ready for plan {}, and nothing was written: {}",
212                fresh.identity.plan_id,
213                ids.join(", ")
214            ),
215        )
216        .expected("the target as ready as it was when the plan was approved")
217        .action("rk reconcile plan computes a fresh plan over what is there now")
218        .target_state("unchanged"),
219    ))
220}
221
222/// Refuse a plan whose operations do not end with exactly one record
223/// write.
224///
225/// Apply stages in the plan's own order and the transaction commits in
226/// staging order, so the record landing before the payload it describes
227/// would leave a target claiming files it does not hold. The planner
228/// emits the record last; this refuses a stored plan that says otherwise.
229///
230/// # Errors
231///
232/// A refusal with [`Reason::StateDrift`] where a record write is not the
233/// final operation, or where more than one record write is present.
234pub fn verify_record_last(plan: &Plan) -> Result<(), RkError> {
235    let records = plan
236        .operations
237        .iter()
238        .filter(|operation| matches!(operation, Operation::WriteRecord { .. }))
239        .count();
240    let last_is_record = plan
241        .operations
242        .last()
243        .is_some_and(|operation| matches!(operation, Operation::WriteRecord { .. }));
244    if records == 0 || (records == 1 && last_is_record) {
245        return Ok(());
246    }
247    let detail = if records > 1 {
248        format!("{records} record writes")
249    } else {
250        "the record write is not the last operation".to_owned()
251    };
252    Err(RkError::refusal(
253        Diagnostic::new(
254            Reason::StateDrift,
255            format!(
256                "plan {} does not write the record last, and nothing was written: {detail}",
257                plan.identity.plan_id
258            ),
259        )
260        .expected("one record write, last, after every file it describes")
261        .action("rk reconcile plan computes a fresh plan")
262        .target_state("unchanged"),
263    ))
264}
265
266/// Refuse where a blob the plan names is missing from the store or no
267/// longer digests to the name it is filed under.
268///
269/// The store files a blob by its digest and reads it back by filename
270/// alone, so this is what turns that filename into a claim the bytes
271/// have to keep. It runs before any staging, so a corrupted store costs
272/// the target nothing.
273///
274/// # Errors
275///
276/// A refusal with [`Reason::StateDrift`] naming every digest whose bytes
277/// are missing or altered, collected in one pass.
278pub fn verify_blobs(plan: &Plan, blobs: &BTreeMap<Digest, Vec<u8>>) -> Result<(), RkError> {
279    let mut bad: Vec<String> = Vec::new();
280    for operation in &plan.operations {
281        let (subject, digest) = match operation {
282            Operation::WriteFile { path, after, .. }
283            | Operation::SpliceBlock { path, after, .. } => (path.clone(), after),
284            Operation::WriteRecord { after, .. } => (manifest::MANIFEST_PATH.to_owned(), after),
285            Operation::RemoveOwnedFile { .. } | Operation::UpdatePin { .. } => continue,
286        };
287        let Some(held) = blobs.get(digest) else {
288            bad.push(format!("{subject} (no blob for {digest})"));
289            continue;
290        };
291        if &Digest::of(held) != digest {
292            bad.push(format!("{subject} (the blob for {digest} was altered)"));
293        }
294    }
295    if bad.is_empty() {
296        return Ok(());
297    }
298    Err(RkError::refusal(
299        Diagnostic::new(
300            Reason::StateDrift,
301            format!(
302                "plan {} names bytes its store no longer holds, and nothing was written: {}",
303                plan.identity.plan_id,
304                bad.join(", ")
305            ),
306        )
307        .expected("every blob digesting to the name the plan filed it under")
308        .action("rk reconcile plan computes a fresh plan and stores its bytes again")
309        .target_state("unchanged"),
310    ))
311}
312
313/// Refuse a stored plan whose semantic inputs no longer match a fresh
314/// computation over the same request.
315///
316/// # Errors
317///
318/// A refusal with [`Reason::StateDrift`] naming every field or
319/// destination whose canonical line changed, collected in one pass.
320pub fn revalidate(stored: &Plan, fresh: &Plan) -> Result<(), RkError> {
321    // The stored document is recomputed too, so a plan edited in the
322    // store after approval reads as moved rather than as approved.
323    let stored_now = fingerprint::compute(stored);
324    if stored.input_fingerprint == fresh.input_fingerprint && stored_now == stored.input_fingerprint
325    {
326        return Ok(());
327    }
328    let before = fingerprint::canonical(stored);
329    let after = fingerprint::canonical(fresh);
330    let before_lines: Vec<&str> = before.lines().collect();
331    let after_lines: Vec<&str> = after.lines().collect();
332    let mut moved: Vec<String> = Vec::new();
333    if stored_now != stored.input_fingerprint {
334        moved.push("the stored plan".to_owned());
335    }
336    for line in before_lines
337        .iter()
338        .filter(|line| !after_lines.contains(line))
339        .chain(
340            after_lines
341                .iter()
342                .filter(|line| !before_lines.contains(line)),
343        )
344    {
345        let label = label(line);
346        if !moved.contains(&label) {
347            moved.push(label);
348        }
349    }
350    Err(RkError::refusal(
351        Diagnostic::new(
352            Reason::StateDrift,
353            format!(
354                "plan {} no longer matches its inputs, and nothing was written: {}",
355                stored.identity.plan_id,
356                moved.join(", ")
357            ),
358        )
359        .expected("the target, the bundle, and the decisions as the plan observed them")
360        .action("rk reconcile plan computes a fresh plan over what is there now")
361        .target_state("unchanged"),
362    ))
363}
364
365/// The human label for one canonical line.
366fn label(line: &str) -> String {
367    let mut parts = line.split('\t');
368    match parts.next().unwrap_or_default() {
369        "target" => "the target".to_owned(),
370        "candidate" => "the candidate bundle".to_owned(),
371        "record" => "the record".to_owned(),
372        "configuration" => "the configuration".to_owned(),
373        "operation" => {
374            let kind = parts.next().unwrap_or_default();
375            let subject = parts.next().unwrap_or_default();
376            format!("operation {kind} {subject}")
377        }
378        "precondition" => format!("precondition {}", parts.next().unwrap_or_default()),
379        "decision" => format!("decision {}", parts.next().unwrap_or_default()),
380        other => other.to_owned(),
381    }
382}
383
384/// Refuse where a destination no longer holds the digest an operation's
385/// `before` names, every mismatch collected in one pass.
386///
387/// # Errors
388///
389/// A refusal with [`Reason::StateDrift`] naming each destination, and
390/// [`RkError::Io`] for a read that fails.
391pub fn verify_before_digests(target: &Utf8Path, plan: &Plan) -> Result<(), RkError> {
392    let mut moved: Vec<String> = Vec::new();
393    let pin = crate::self_depend::observe(target).ok();
394    for operation in &plan.operations {
395        let (subject, held, wanted): (String, Option<String>, Option<String>) = match operation {
396            Operation::WriteFile { path, before, .. }
397            | Operation::SpliceBlock { path, before, .. } => (
398                path.clone(),
399                landing::read_recorded(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
400                before.as_ref().map(ToString::to_string),
401            ),
402            Operation::RemoveOwnedFile { path, before } => (
403                path.clone(),
404                read_optional(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
405                Some(before.to_string()),
406            ),
407            Operation::WriteRecord { before, .. } => (
408                manifest::MANIFEST_PATH.to_owned(),
409                read_optional(target, manifest::MANIFEST_PATH)?
410                    .map(|bytes| Digest::of(&bytes).to_string()),
411                before.as_ref().map(ToString::to_string),
412            ),
413            Operation::UpdatePin {
414                manager, before, ..
415            } => (
416                format!("the {manager} pin"),
417                pin.as_ref().and_then(|observed| {
418                    parse_manager(manager)
419                        .and_then(|m| observed.entry(m))
420                        .and_then(|entry| entry.version.clone())
421                }),
422                Some(before.clone()),
423            ),
424        };
425        if held != wanted {
426            moved.push(subject);
427        }
428    }
429    if moved.is_empty() {
430        return Ok(());
431    }
432    Err(RkError::refusal(
433        Diagnostic::new(
434            Reason::StateDrift,
435            format!(
436                "these destinations changed since the plan observed them, and nothing was written: {}",
437                moved.join(", ")
438            ),
439        )
440        .expected("every destination holding what the plan's before digest names")
441        .action("rk reconcile plan computes a fresh plan over what is there now")
442        .target_state("unchanged"),
443    ))
444}
445
446/// Execute one gated, revalidated plan: stage every operation, commit
447/// the renames in order, run the postconditions, and journal the run.
448///
449/// # Errors
450///
451/// The gate's and the revalidation's refusals, a refusal for a
452/// destination that moved, and [`RkError::Io`] for a staged write or a
453/// rename that fails, whose message names every destination that landed
454/// before it. A failed postcondition is not an error here: the report
455/// carries it, and [`Applied::failure`] is the error the caller returns
456/// after rendering.
457pub fn run(
458    target: &Utf8Path,
459    stored: &Plan,
460    blobs: &BTreeMap<Digest, Vec<u8>>,
461    fresh: &Plan,
462    journal: Option<Journal>,
463) -> Result<Applied, RkError> {
464    let _lock = super::lock::acquire(target)?;
465    run_locked(target, stored, blobs, fresh, journal)
466}
467
468/// The apply whose caller already holds the target.
469///
470/// `rk reconcile apply` observes the world to recompute `fresh` before
471/// it calls this, and that observation belongs inside the lock too, so
472/// it takes the target itself and comes here.
473///
474/// # Errors
475///
476/// As [`run`], without the acquisition's own refusal.
477pub fn run_locked(
478    target: &Utf8Path,
479    stored: &Plan,
480    blobs: &BTreeMap<Digest, Vec<u8>>,
481    fresh: &Plan,
482    mut journal: Option<Journal>,
483) -> Result<Applied, RkError> {
484    let outcome = execute(target, stored, blobs, fresh, journal.as_mut());
485    if let Some(journal) = journal.as_mut() {
486        match &outcome {
487            Ok(applied) => {
488                let failed = applied.failed();
489                if failed.is_empty() {
490                    journal.finish(0, None);
491                } else {
492                    journal.finish(1, Some(Reason::PostconditionFailed.as_str()));
493                }
494            }
495            Err(error) => {
496                journal.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
497            }
498        }
499    }
500    let run_id = journal.as_ref().map(|journal| journal.run_id().to_owned());
501    outcome.map(|mut applied| {
502        applied.run_id = run_id;
503        applied
504    })
505}
506
507fn execute(
508    target: &Utf8Path,
509    stored: &Plan,
510    blobs: &BTreeMap<Digest, Vec<u8>>,
511    fresh: &Plan,
512    mut journal: Option<&mut Journal>,
513) -> Result<Applied, RkError> {
514    gate(stored)?;
515    // Revalidation first, because it names what moved. The fresh gate
516    // then catches what the fingerprint cannot see: a precondition that
517    // turned decision-required since the plan was stored leaves every
518    // canonical line identical, because canonicalization excludes
519    // decision-required evaluations, so only the fresh readiness sees it.
520    revalidate(stored, fresh)?;
521    gate_fresh(fresh)?;
522    verify_record_last(stored)?;
523    verify_blobs(stored, blobs)?;
524    verify_before_digests(target, stored)?;
525    if let Some(journal) = journal.as_deref_mut() {
526        journal.event_line(&format!(
527            r#"{{"event":"plan","plan_id":"{}","input_fingerprint":"{}","operations":{}}}"#,
528            stored.identity.plan_id,
529            stored.input_fingerprint,
530            stored.operations.len()
531        ));
532    }
533    let (txn, removals) = stage(target, stored, blobs)?;
534    // The interruption proof's seam: a rename stopped on purpose.
535    let stop = std::env::var_os("RK_APPLY_INTERRUPT_AT").map(std::path::PathBuf::from);
536    let landed = txn
537        .commit_stopping_at(stop.as_deref())
538        .map_err(|interrupted| {
539            if let Some(journal) = journal.as_deref_mut() {
540                for path in &interrupted.landed {
541                    journal.event_line(&format!(
542                        r#"{{"event":"operation","path":"{}","status":"ok"}}"#,
543                        path.display()
544                    ));
545                }
546                journal.event_line(&format!(
547                    r#"{{"event":"operation","path":"{}","status":"failed","detail":"{}"}}"#,
548                    interrupted.failed.display(),
549                    interrupted.error
550                ));
551            }
552            interrupted_error(&interrupted)
553        })?;
554    for path in &removals {
555        std::fs::remove_file(target.join(path))?;
556    }
557    debug_assert_eq!(landed.len(), txn_len(&stored.operations));
558    let operations: Vec<OperationResult> = stored
559        .operations
560        .iter()
561        .map(|operation| OperationResult {
562            op: operation.kind(),
563            path: match operation {
564                Operation::WriteRecord { .. } => Some(manifest::MANIFEST_PATH.to_owned()),
565                Operation::UpdatePin { manager, .. } => Some(format!("the {manager} pin")),
566                other => other.path().map(str::to_owned),
567            },
568            status: "ok",
569        })
570        .collect();
571    let postconditions = postconditions(target, stored);
572    if let Some(journal) = journal {
573        for result in &operations {
574            journal.event_line(&format!(
575                r#"{{"event":"operation","op":"{}","path":"{}","status":"{}"}}"#,
576                result.op,
577                result.path.as_deref().unwrap_or_default(),
578                result.status
579            ));
580        }
581        for result in &postconditions {
582            journal.event_line(&format!(
583                r#"{{"event":"postcondition","check":"{}","status":"{}"}}"#,
584                result.check, result.status
585            ));
586        }
587    }
588    Ok(Applied {
589        schema: APPLY_SCHEMA,
590        plan_id: stored.identity.plan_id.clone(),
591        input_fingerprint: stored.input_fingerprint.clone(),
592        target: target.to_string(),
593        classification: stored.classification,
594        operations,
595        postconditions,
596        run_id: None,
597        next: vec![
598            "commit the written files, the record included".to_owned(),
599            format!("rk status --target {target} reports the result"),
600        ],
601    })
602}
603
604/// Every write staged before any rename, so an unreadable destination
605/// or a missing blob surfaces while the target is still untouched. The
606/// removals come back beside the transaction, because a removal is not
607/// a rename and runs after the commit.
608fn stage(
609    target: &Utf8Path,
610    stored: &Plan,
611    blobs: &BTreeMap<Digest, Vec<u8>>,
612) -> Result<(crate::atomic::Transaction, Vec<String>), RkError> {
613    let mut txn = crate::atomic::Transaction::new();
614    let mut removals: Vec<String> = Vec::new();
615    let pin = stored
616        .operations
617        .iter()
618        .any(|operation| matches!(operation, Operation::UpdatePin { .. }))
619        .then(|| crate::self_depend::observe(target))
620        .transpose()?;
621    for operation in &stored.operations {
622        match operation {
623            Operation::WriteFile { path, after, .. } => {
624                txn.stage(target.join(path).as_std_path(), blob(blobs, after)?)?;
625            }
626            Operation::SpliceBlock { path, after, .. } => {
627                let existing = read_optional(target, path)?;
628                let block = String::from_utf8_lossy(blob(blobs, after)?).into_owned();
629                if path == landing::HOOKS_DESTINATION {
630                    let text = existing.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
631                    let spliced = landing::splice_hooks_block(text.as_deref(), &block)
632                        .map_err(std::io::Error::other)?;
633                    txn.stage(target.join(path).as_std_path(), spliced.as_bytes())?;
634                } else {
635                    // The document is the target's bytes: a markdown
636                    // splice decodes none of them.
637                    let spliced = landing::splice_marked_block(existing.as_deref(), &block);
638                    txn.stage(target.join(path).as_std_path(), &spliced)?;
639                }
640            }
641            Operation::RemoveOwnedFile { path, .. } => removals.push(path.clone()),
642            Operation::WriteRecord { after, .. } => {
643                txn.stage(
644                    target.join(manifest::MANIFEST_PATH).as_std_path(),
645                    blob(blobs, after)?,
646                )?;
647            }
648            Operation::UpdatePin {
649                manager,
650                before,
651                after,
652            } => {
653                let (file, text) = pin_text(pin.as_ref(), manager, before)?;
654                let PinRead::One { line, .. } =
655                    manager::read_pin(parse_manager(manager).unwrap_or(Manager::Mise), &text)
656                else {
657                    return Err(pin_moved(manager));
658                };
659                let rewritten = manager::rewrite_line(&text, line, before, after);
660                txn.stage(target.join(&file).as_std_path(), rewritten.as_bytes())?;
661            }
662        }
663    }
664    Ok((txn, removals))
665}
666
667/// The failure of a commit that stopped part way, naming every
668/// destination that landed before it, under the I/O kind that stopped it.
669fn interrupted_error(interrupted: &crate::atomic::Interrupted) -> RkError {
670    RkError::Io(std::io::Error::new(
671        interrupted.error.kind(),
672        format!(
673            "the apply stopped at {}: {}; each destination holds its previous bytes or its new ones, and these landed before it: {}",
674            interrupted.failed.display(),
675            interrupted.error,
676            if interrupted.landed.is_empty() {
677                "none".to_owned()
678            } else {
679                interrupted
680                    .landed
681                    .iter()
682                    .map(|path| path.display().to_string())
683                    .collect::<Vec<_>>()
684                    .join(", ")
685            }
686        ),
687    ))
688}
689
690/// How many staged renames the operations produce.
691fn txn_len(operations: &[Operation]) -> usize {
692    operations
693        .iter()
694        .filter(|operation| !matches!(operation, Operation::RemoveOwnedFile { .. }))
695        .count()
696}
697
698/// Run every postcondition the plan carries and report each outcome.
699#[must_use]
700pub fn postconditions(target: &Utf8Path, plan: &Plan) -> Vec<PostconditionResult> {
701    let pin = plan
702        .postconditions
703        .iter()
704        .any(|check| matches!(check, Postcondition::PinReads { .. }))
705        .then(|| crate::self_depend::observe(target).ok())
706        .flatten();
707    plan.postconditions
708        .iter()
709        .map(|check| {
710            let (name, outcome): (String, Result<(), String>) = match check {
711                Postcondition::RecordReadsBack { sha256 } => (
712                    "record-reads-back".to_owned(),
713                    holds(read_optional(target, manifest::MANIFEST_PATH), sha256),
714                ),
715                Postcondition::DestinationHolds { path, sha256 } => (
716                    format!("destination-holds:{path}"),
717                    holds(
718                        landing::read_recorded(target, path).map_err(RkError::Io),
719                        sha256,
720                    ),
721                ),
722                Postcondition::StatusCheckClean => {
723                    ("status-check-clean".to_owned(), status_check(target))
724                }
725                Postcondition::PinReads { manager, version } => (
726                    format!("pin-reads:{manager}"),
727                    match pin
728                        .as_ref()
729                        .and_then(|observed| parse_manager(manager).and_then(|m| observed.entry(m)))
730                        .and_then(|entry| entry.version.clone())
731                    {
732                        Some(found) if &found == version => Ok(()),
733                        Some(found) => Err(format!("the {manager} pin reads {found}")),
734                        None => Err(format!("no {manager} pin reads")),
735                    },
736                ),
737            };
738            // The status check judges the whole target, sentinels the
739            // operator still owes included, so its answer is reported and
740            // never fails an apply whose own writes landed as promised.
741            let advisory = matches!(check, Postcondition::StatusCheckClean);
742            match outcome {
743                Ok(()) => PostconditionResult {
744                    check: name,
745                    status: "ok",
746                    detail: None,
747                },
748                Err(detail) => PostconditionResult {
749                    check: name,
750                    status: if advisory { "reported" } else { "failed" },
751                    detail: Some(detail),
752                },
753            }
754        })
755        .collect()
756}
757
758/// Whether the bytes read digest to what the plan promised.
759fn holds(read: Result<Option<Vec<u8>>, RkError>, wanted: &Digest) -> Result<(), String> {
760    match read {
761        Ok(Some(bytes)) if Digest::of(&bytes) == *wanted => Ok(()),
762        Ok(Some(bytes)) => Err(format!("holds {}", Digest::of(&bytes))),
763        Ok(None) => Err("absent".to_owned()),
764        Err(error) => Err(error.to_string()),
765    }
766}
767
768/// `rk status --check` as the standing postcondition: this binary run
769/// against the target, judged by its exit code.
770fn status_check(target: &Utf8Path) -> Result<(), String> {
771    let exe = std::env::current_exe().map_err(|error| format!("no engine path: {error}"))?;
772    let mut command = std::process::Command::new(exe);
773    for var in crate::maintenance::GIT_HOOK_VARS {
774        command.env_remove(var);
775    }
776    let out = command
777        .args(["status", "--check", "--target"])
778        .arg(target)
779        .output()
780        .map_err(|error| format!("rk status --check could not run: {error}"))?;
781    if out.status.success() {
782        return Ok(());
783    }
784    let stderr = String::from_utf8_lossy(&out.stderr);
785    Err(stderr
786        .lines()
787        .find(|line| !line.trim().is_empty())
788        .unwrap_or("rk status --check exited nonzero")
789        .trim()
790        .to_owned())
791}
792
793fn blob<'a>(blobs: &'a BTreeMap<Digest, Vec<u8>>, digest: &Digest) -> Result<&'a [u8], RkError> {
794    blobs.get(digest).map(Vec::as_slice).ok_or_else(|| {
795        RkError::Other(anyhow::anyhow!(
796            "the plan names digest {digest} and its store holds no such blob"
797        ))
798    })
799}
800
801fn read_optional(target: &Utf8Path, rel: &str) -> Result<Option<Vec<u8>>, RkError> {
802    match std::fs::read(target.join(rel)) {
803        Ok(bytes) => Ok(Some(bytes)),
804        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
805        Err(error) => Err(RkError::Io(error)),
806    }
807}
808
809fn parse_manager(name: &str) -> Option<Manager> {
810    Manager::ALL.into_iter().find(|m| m.as_str() == name)
811}
812
813/// The wired manager's file and text, for the one-fact rewrite the
814/// apply stages; the flake pair is never moved here.
815fn pin_text(
816    observed: Option<&crate::self_depend::Observed>,
817    manager: &str,
818    before: &str,
819) -> Result<(String, String), RkError> {
820    let parsed = parse_manager(manager).ok_or_else(|| pin_moved(manager))?;
821    if parsed == Manager::Flake {
822        return Err(RkError::refusal(
823            Diagnostic::new(
824                Reason::PrerequisiteUnmet,
825                "the flake pin moves through the self-depend sync verb under --apply, with nix and the network; an offline apply cannot stage it",
826            )
827            .expected("a one-fact manager pin, or the sync verb for the flake pair")
828            .target_state("unchanged"),
829        ));
830    }
831    let entry = observed
832        .and_then(|observed| observed.entry(parsed))
833        .filter(|entry| entry.version.as_deref() == Some(before))
834        .ok_or_else(|| pin_moved(manager))?;
835    match (&entry.file, &entry.text) {
836        (Some(file), Some(text)) => Ok((file.clone(), text.clone())),
837        _ => Err(pin_moved(manager)),
838    }
839}
840
841fn pin_moved(manager: &str) -> RkError {
842    RkError::refusal(
843        Diagnostic::new(
844            Reason::StateDrift,
845            format!(
846                "the {manager} pin no longer reads as the plan observed it, and nothing was written"
847            ),
848        )
849        .expected("the manager file as the plan observed it")
850        .action("rk reconcile plan computes a fresh plan over what is there now")
851        .target_state("unchanged"),
852    )
853}
854
855#[cfg(test)]
856mod tests {
857    use camino::Utf8PathBuf;
858
859    use super::{PostconditionResult, postconditions};
860    use crate::digest::Digest;
861    use crate::plan::{Plan, Postcondition};
862
863    /// A plan whose postconditions run against a scratch target.
864    fn plan_with(checks: &[Postcondition]) -> Plan {
865        let json = serde_json::json!({
866            "schema": crate::plan::PLAN_SCHEMA,
867            "identity": {"plan_id": "0123456789abcdef", "created_at": "2026-01-01T00:00:00Z", "engine_version": "0.0.0"},
868            "classification": "setup",
869            "findings": [],
870            "desired_state": {"intent": "reconcile", "selector": "embedded", "release": {"version": "0.0.0", "venue": "embedded", "payload_sha256": Digest::of(b"a").to_string(), "payload_schema": 1}},
871            "observed_state": {
872                "repository": {"target": "/tmp/t", "git": false, "tags": 0, "long_lived_branches": [], "release_markers": [], "collisions": [], "verdict": "greenfield", "evidence_refs": []},
873                "installation": {"record": {"state": "absent"}, "configuration": {"present": false, "pending": []}, "destinations": [], "evidence_refs": []},
874                "host": {"engine_version": "0.0.0", "evidence_refs": []},
875                "forge": {"state": "not-observed", "reason": "not requested"}
876            },
877            "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, "intermediate": [], "evidence_refs": []}, "guidance": {"coverage": {"state": "not-needed"}, "steps": [], "excluded": 0, "evidence_refs": []}},
878            "operations": [],
879            "preconditions": [],
880            "decisions": [],
881            "postconditions": checks,
882            "evidence": [],
883            "readiness": "ready",
884            "input_fingerprint": Digest::of(b"a").to_string()
885        });
886        serde_json::from_value(json).expect("a plan deserializes")
887    }
888
889    /// Every postcondition runs and reports; a destination holding other
890    /// bytes than promised is reported failed with what it holds, and
891    /// the check beside it still reports ok.
892    #[test]
893    fn postconditions_run_and_a_failure_is_reported() {
894        let dir = tempfile::tempdir().expect("a scratch target exists");
895        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
896        std::fs::write(target.join("SECURITY.md"), b"held").expect("writes");
897        let plan = plan_with(&[
898            Postcondition::DestinationHolds {
899                path: "SECURITY.md".into(),
900                sha256: Digest::of(b"held"),
901            },
902            Postcondition::DestinationHolds {
903                path: "SECURITY.md".into(),
904                sha256: Digest::of(b"promised"),
905            },
906            Postcondition::RecordReadsBack {
907                sha256: Digest::of(b"record"),
908            },
909        ]);
910        let results: Vec<PostconditionResult> = postconditions(&target, &plan);
911        assert_eq!(results.len(), 3);
912        assert_eq!(results[0].status, "ok");
913        assert_eq!(results[1].status, "failed");
914        assert_eq!(
915            results[1].detail.as_deref(),
916            Some(format!("holds {}", Digest::of(b"held")).as_str())
917        );
918        assert_eq!(results[2].status, "failed");
919        assert_eq!(results[2].detail.as_deref(), Some("absent"));
920        let applied = super::Applied {
921            schema: super::APPLY_SCHEMA,
922            plan_id: "0123456789abcdef".into(),
923            input_fingerprint: Digest::of(b"a"),
924            target: target.to_string(),
925            classification: crate::plan::Classification::Setup,
926            operations: vec![],
927            postconditions: results,
928            run_id: None,
929            next: vec![],
930        };
931        let failure = applied
932            .failure()
933            .expect("a failed postcondition is a failure");
934        assert_eq!(failure.exit_code(), 1);
935        assert_eq!(
936            failure.reason(),
937            crate::diagnostic::Reason::PostconditionFailed
938        );
939    }
940
941    /// The apply report's shape, held by snapshot.
942    #[test]
943    fn the_apply_report_schema_snapshot_holds() {
944        let applied = super::Applied {
945            schema: super::APPLY_SCHEMA,
946            plan_id: "0123456789abcdef".into(),
947            input_fingerprint: Digest::of(b"a"),
948            target: "/tmp/t".into(),
949            classification: crate::plan::Classification::Upgrade,
950            operations: vec![super::OperationResult {
951                op: "write-record",
952                path: Some(".release-kit/manifest.json".into()),
953                status: "ok",
954            }],
955            postconditions: vec![PostconditionResult {
956                check: "record-reads-back".into(),
957                status: "failed",
958                detail: Some("absent".into()),
959            }],
960            run_id: Some("run".into()),
961            next: vec!["commit the written files, the record included".into()],
962        };
963        assert_eq!(
964            serde_json::to_string(&applied).expect("serializes"),
965            format!(
966                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"]}}"#,
967                Digest::of(b"a")
968            )
969        );
970    }
971}