Skip to main content

release_kit/plan/
planner.rs

1//! The pure planner: from an observation, a desired state, and two
2//! bundles read through the seam, one plan.
3//!
4//! No filesystem, no network, and no clock inside: the observation was
5//! gathered before, the bundles answer through [`ReleaseSource`], and the
6//! instant is an input. The same inputs produce the same plan and the
7//! same fingerprint, which is the first thing the tests hold.
8
9use std::collections::BTreeMap;
10
11use crate::digest::Digest;
12use crate::error::RkError;
13use crate::landing::manifest::{self, Alignment, FileRecord, Manifest, Parameters};
14use crate::landing::{self, Entry, Kind, Placement};
15use crate::release::{PAYLOAD_SCHEMA, ReleaseManifest, ReleaseSource};
16
17use super::classify::{self, Finding, RecordState as ClassifyRecord, Verdict};
18use super::evidence::EvidenceKind;
19use super::gather::{ConfigRead, ForgeRead, Observation, RecordRead, Resolution};
20use super::operation::Operation;
21use super::readiness::{self, Evaluation, Precondition, Requirement};
22use super::{
23    BaselineState, BundleIdentity, Choice, Compatibility, Configuration, ConfigurationState,
24    Decision, DesiredState, Destination, DestinationOutcome, Disposition, ForgeState, Guidance,
25    Host, Identity, Installation, Intent, ObservedState, PLAN_SCHEMA, Plan, Planned, Postcondition,
26    RecordState, Release, Repository, ResolvedRelease, Verification, fingerprint,
27};
28
29/// The candidate bundle, as the planner receives it.
30pub struct Candidate<'a> {
31    /// The source the candidate's bytes are read through.
32    pub source: &'a dyn ReleaseSource,
33    /// The candidate's manifest, read once.
34    pub manifest: ReleaseManifest,
35    /// Where it was read from: `embedded`, `crates`, or `directory`.
36    pub venue: &'a str,
37    /// How it was verified.
38    pub verification: Verification,
39}
40
41/// The recorded release's bundle, as the planner receives it.
42pub enum Baseline<'a> {
43    /// No record, so no baseline is needed.
44    NotNeeded,
45    /// The recorded payload is the one compiled into this engine.
46    Embedded(&'a dyn ReleaseSource),
47    /// The recorded release's bundle, read from the release cache.
48    Cached {
49        /// The recorded version.
50        version: String,
51        /// The source.
52        source: &'a dyn ReleaseSource,
53    },
54    /// The recorded release's bundle could not be read.
55    NotObserved {
56        /// Why.
57        reason: String,
58    },
59}
60
61/// Everything the planner reads.
62pub struct Inputs<'a> {
63    /// What the caller asked the plan to be.
64    pub intent: Intent,
65    /// The instant the plan is computed, RFC 3339.
66    pub clock: &'a str,
67    /// The engine computing it.
68    pub engine_version: &'a str,
69    /// The selector as given.
70    pub selector: &'a str,
71    /// The candidate bundle.
72    pub candidate: Candidate<'a>,
73    /// The recorded release's bundle.
74    pub baseline: Baseline<'a>,
75    /// What was observed at the target.
76    pub observation: Observation,
77    /// The landing parameters, resolved or not.
78    pub resolution: Resolution,
79    /// The decisions the operator selected, by id.
80    pub selected: &'a BTreeMap<String, String>,
81}
82
83/// What the three-way comparison decided for one destination.
84struct Compared<'a> {
85    entry: &'a Entry,
86    /// The digest the destination holds now, or absent.
87    before: Option<Digest>,
88    /// Whether the candidate's bytes are written.
89    write: bool,
90    /// Whether the target edited a file release-kit owns.
91    conflict: bool,
92    /// Whether a rendered file the record names is missing from the disk.
93    missing: bool,
94    /// The record entry after the apply.
95    record: FileRecord,
96}
97
98/// Compute the plan.
99///
100/// # Errors
101///
102/// Returns the seam's own failures where a bundle cannot be read, and a
103/// serialization failure for the planned record, which is a defect.
104#[allow(
105    clippy::too_many_lines,
106    reason = "one plan is one linear derivation from observation to fingerprint, and cutting it would separate a section from the inputs it cites"
107)]
108pub fn plan(inputs: Inputs<'_>) -> Result<Planned, RkError> {
109    let Inputs {
110        intent,
111        clock,
112        engine_version,
113        selector,
114        candidate,
115        baseline,
116        mut observation,
117        resolution,
118        selected,
119    } = inputs;
120    let mut blobs: BTreeMap<Digest, Vec<u8>> = BTreeMap::new();
121    let mut findings: Vec<Finding> = Vec::new();
122    let mut preconditions: Vec<Precondition> = Vec::new();
123    let mut decisions: Vec<Decision> = Vec::new();
124
125    // The candidate, cited by everything derived from it.
126    let candidate_ref = observation.ledger.observe(
127        "candidate-bundle",
128        EvidenceKind::Bundle,
129        candidate.venue,
130        clock,
131        Some(candidate.manifest.payload_sha256.clone()),
132        format!("manifest through the {} source", candidate.venue),
133    );
134    let baseline_state = match &baseline {
135        Baseline::NotNeeded => BaselineState::NotNeeded,
136        Baseline::Embedded(_) => BaselineState::Embedded,
137        Baseline::Cached { version, .. } => BaselineState::Cached {
138            version: version.clone(),
139        },
140        Baseline::NotObserved { reason } => BaselineState::NotObserved {
141            reason: reason.clone(),
142        },
143    };
144    let baseline_source: Option<&dyn ReleaseSource> = match &baseline {
145        Baseline::Embedded(source) | Baseline::Cached { source, .. } => Some(*source),
146        Baseline::NotNeeded | Baseline::NotObserved { .. } => None,
147    };
148    let baseline_ref = baseline_source.map(|source| {
149        let digest = source
150            .manifest()
151            .ok()
152            .map(|manifest| manifest.payload_sha256);
153        observation.ledger.observe(
154            "baseline-bundle",
155            EvidenceKind::Bundle,
156            "recorded release",
157            clock,
158            digest,
159            "manifest through the seam",
160        )
161    });
162
163    // The verdict and the record state.
164    let verdict = classify::verdict(&observation.facts);
165    let record_state = match &observation.record {
166        RecordRead::Absent => ClassifyRecord::Absent,
167        RecordRead::Present { .. } => ClassifyRecord::Present,
168        RecordRead::Invalid { .. } => ClassifyRecord::Invalid,
169    };
170    let recorded: Option<&Manifest> = match &observation.record {
171        RecordRead::Present { manifest, .. } => Some(manifest),
172        RecordRead::Absent | RecordRead::Invalid { .. } => None,
173    };
174    if recorded.is_none() {
175        for marker in &observation.facts.release_markers {
176            findings.push(Finding {
177                code: "release-marker".into(),
178                detail: marker.clone(),
179            });
180        }
181        for collision in &observation.facts.collisions {
182            findings.push(Finding {
183                code: "payload-collision".into(),
184                detail: collision.clone(),
185            });
186        }
187        if observation.facts.tags > 0 {
188            findings.push(Finding {
189                code: "tag".into(),
190                detail: format!(
191                    "{} tags with no mechanism behind them",
192                    observation.facts.tags
193                ),
194            });
195        }
196        for branch in &observation.facts.long_lived_branches {
197            findings.push(Finding {
198                code: "long-lived-branch".into(),
199                detail: branch.clone(),
200            });
201        }
202    }
203    if let RecordRead::Invalid { reason } = &observation.record {
204        findings.push(Finding {
205            code: "record-invalid".into(),
206            detail: reason.clone(),
207        });
208    }
209
210    // The projection under the resolved parameters, where they resolved.
211    let mut entries: Vec<Entry> = Vec::new();
212    if let Some(params) = &resolution.params {
213        entries = landing::projection(candidate.source, params)?;
214        if let Some((set, _)) = &resolution.nix_withheld {
215            entries.retain(|entry| !set.iter().any(|path| path == &entry.destination));
216        }
217    }
218
219    // The three-way comparison, in the one-pass shape the landing rules
220    // bind: every conflict collected, nothing refused one at a time.
221    let mut compared: Vec<Compared<'_>> = Vec::new();
222    for entry in &entries {
223        let disk = observation.files.get(&entry.destination).map(Vec::as_slice);
224        let before = disk.map(Digest::of);
225        let comparison = recorded.map_or_else(
226            || compare_fresh(entry, disk),
227            |record| compare_recorded(entry, record.file(&entry.destination), disk),
228        );
229        if comparison.write {
230            blobs.insert(Digest::of(&entry.rendered), entry.rendered.clone());
231            if let Some(bytes) = disk {
232                blobs.insert(Digest::of(bytes), bytes.to_vec());
233            }
234        }
235        if comparison.conflict {
236            if let Some(bytes) = disk {
237                blobs.insert(Digest::of(bytes), bytes.to_vec());
238            }
239            blobs.insert(Digest::of(&entry.rendered), entry.rendered.clone());
240            if let (Some(source), Some(record)) = (baseline_source, recorded) {
241                if let Some(baseline_bytes) = baseline_bytes(source, record, entry) {
242                    blobs.insert(Digest::of(&baseline_bytes), baseline_bytes);
243                }
244            }
245        }
246        compared.push(Compared {
247            entry,
248            before,
249            write: comparison.write,
250            conflict: comparison.conflict,
251            missing: comparison.missing,
252            record: comparison.record,
253        });
254    }
255    let owned_drift = compared.iter().any(|c| c.conflict) || observation.hooks_defect.is_some();
256    for c in compared.iter().filter(|c| c.conflict) {
257        findings.push(Finding {
258            code: if c.missing {
259                "owned-missing".into()
260            } else {
261                "owned-drift".into()
262            },
263            detail: c.entry.destination.clone(),
264        });
265    }
266    if let Some(defect) = &observation.hooks_defect {
267        findings.push(Finding {
268            code: "owned-drift".into(),
269            detail: defect.clone(),
270        });
271    }
272    let classification = classify::classify(record_state, verdict, owned_drift);
273    let outcomes: Vec<DestinationOutcome> = compared
274        .iter()
275        .map(|c| DestinationOutcome {
276            path: c.entry.destination.clone(),
277            kind: c.entry.kind,
278            recorded: recorded.is_some_and(|record| record.file(&c.entry.destination).is_some()),
279            disposition: disposition(c, recorded, observation.files.get(&c.entry.destination)),
280        })
281        .collect();
282
283    // The fronts fix what the plan may contain: a first landing refuses
284    // a record, an upgrade needs one, and an adoption verifies every
285    // destination and writes none.
286    let intent_holds = !matches!(
287        (intent, record_state),
288        (Intent::Setup | Intent::Adopt, ClassifyRecord::Present)
289            | (Intent::Upgrade, ClassifyRecord::Absent)
290    );
291    let adopt_missing: Vec<&Compared<'_>> = if intent == Intent::Adopt {
292        compared.iter().filter(|c| c.write).collect()
293    } else {
294        Vec::new()
295    };
296
297    // The operations, in apply order: files, then the configuration,
298    // then the pin, then the record, last.
299    let mut operations: Vec<Operation> = Vec::new();
300    for c in compared
301        .iter()
302        .filter(|c| c.write && !c.conflict && intent != Intent::Adopt)
303    {
304        let after = Digest::of(&c.entry.rendered);
305        operations.push(match c.entry.placement {
306            Placement::Whole => Operation::WriteFile {
307                path: c.entry.destination.clone(),
308                kind: c.entry.kind,
309                before: c.before.clone(),
310                after,
311            },
312            Placement::Block => Operation::SpliceBlock {
313                path: c.entry.destination.clone(),
314                marker: landing::block_markers(&c.entry.destination)
315                    .map_or("", |(begin, _)| begin)
316                    .to_owned(),
317                before: c.before.clone(),
318                after,
319            },
320        });
321    }
322    let candidate_version = candidate.manifest.release_kit_version.clone();
323    let landing_planned = matches!(
324        (&resolution.params, record_state),
325        (Some(_), ClassifyRecord::Absent | ClassifyRecord::Present)
326    ) && !owned_drift
327        && intent_holds
328        && adopt_missing.is_empty();
329    let config = match (&resolution.params, &observation.config) {
330        (Some(params), ConfigRead::Absent) => {
331            Some(crate::config::Plan::compose(None, params, None, recorded)?)
332        }
333        (Some(params), ConfigRead::Present { config, bytes }) => {
334            Some(crate::config::Plan::compose(
335                Some(&String::from_utf8_lossy(bytes)),
336                params,
337                Some(config),
338                recorded,
339            )?)
340        }
341        _ => None,
342    };
343    if let Some(config) = config.as_ref().filter(|_| landing_planned) {
344        let after_bytes = config.content.as_bytes().to_vec();
345        let before = match &observation.config {
346            ConfigRead::Present { bytes, .. } | ConfigRead::Invalid { bytes, .. } => {
347                Some(Digest::of(bytes))
348            }
349            ConfigRead::Absent => None,
350        };
351        let after = Digest::of(&after_bytes);
352        if before.as_ref() != Some(&after) {
353            blobs.insert(after.clone(), after_bytes);
354            if let ConfigRead::Present { bytes, .. } = &observation.config {
355                blobs.insert(Digest::of(bytes), bytes.clone());
356            }
357            operations.push(Operation::WriteFile {
358                path: crate::config::CONFIG_PATH.to_owned(),
359                kind: Kind::State,
360                before,
361                after,
362            });
363        }
364    }
365    let mut flake_pin_behind: Option<String> = None;
366    if let Some(pin) = &observation.pin {
367        if trim_v(&pin.version) != trim_v(&candidate_version) {
368            // A one-fact manager moves by one rewrite the apply can stage.
369            // The flake pair needs nix and the network, which an offline
370            // apply never has, so that move stays the sync verb's.
371            if pin.manager == "flake" {
372                flake_pin_behind = Some(format!(
373                    "the flake pin records {} and the candidate is {candidate_version}; the self-depend sync verb moves it under --apply, with nix and the network",
374                    pin.version
375                ));
376            } else if landing_planned {
377                let recorded_form = crate::self_depend::manager::Manager::ALL
378                    .into_iter()
379                    .find(|m| m.as_str() == pin.manager)
380                    .map_or_else(
381                        || candidate_version.clone(),
382                        |m| m.recorded(&candidate_version),
383                    );
384                operations.push(Operation::UpdatePin {
385                    manager: pin.manager.clone(),
386                    before: pin.version.clone(),
387                    after: recorded_form,
388                });
389            }
390        }
391    }
392    let planned_record = match (&resolution.params, record_state) {
393        (Some(params), ClassifyRecord::Absent | ClassifyRecord::Present) if landing_planned => {
394            let record = planned_manifest(
395                &candidate,
396                params,
397                recorded,
398                intent,
399                clock,
400                compared.iter().map(|c| &c.record),
401            );
402            let bytes = manifest::render(&record)?;
403            let after = Digest::of(&bytes);
404            let before = match &observation.record {
405                RecordRead::Present { bytes, .. } => Some(Digest::of(bytes)),
406                RecordRead::Absent | RecordRead::Invalid { .. } => None,
407            };
408            if before.as_ref() == Some(&after) {
409                None
410            } else {
411                blobs.insert(after.clone(), bytes);
412                if let RecordRead::Present { bytes, .. } = &observation.record {
413                    blobs.insert(Digest::of(bytes), bytes.clone());
414                }
415                Some(Operation::WriteRecord { before, after })
416            }
417        }
418        _ => None,
419    };
420    if let Some(operation) = planned_record {
421        operations.push(operation);
422    }
423
424    // The preconditions, each with its requirement.
425    let record_ref = observation.refs.record.clone();
426    let config_ref = observation.refs.configuration.clone();
427    let resolution_refs: Vec<String> = record_ref
428        .iter()
429        .chain(config_ref.iter())
430        .chain(observation.refs.repository.iter())
431        .cloned()
432        .collect();
433    preconditions.push(Precondition {
434        id: "technology-resolved".into(),
435        requirement: Requirement::Required,
436        evaluation: resolution
437            .unresolved
438            .as_ref()
439            .map_or(Evaluation::Satisfied, |reason| Evaluation::Unsatisfied {
440                reason: reason.clone(),
441            }),
442        decision: None,
443        evidence_refs: resolution_refs.clone(),
444    });
445    match (intent, record_state) {
446        (Intent::Setup | Intent::Adopt, ClassifyRecord::Present) => {
447            preconditions.push(Precondition {
448                id: "record-absent".into(),
449                requirement: Requirement::Required,
450                evaluation: Evaluation::Unsatisfied {
451                    reason: format!(
452                        "the target already carries {}; rk upgrade takes it to a newer payload",
453                        manifest::MANIFEST_PATH
454                    ),
455                },
456                decision: None,
457                evidence_refs: record_ref.iter().cloned().collect(),
458            });
459        }
460        (Intent::Upgrade, ClassifyRecord::Absent) => {
461            preconditions.push(Precondition {
462                id: "record-present".into(),
463                requirement: Requirement::Required,
464                evaluation: Evaluation::Unsatisfied {
465                    reason: format!(
466                        "no {} at the target: there is no baseline to upgrade against",
467                        manifest::MANIFEST_PATH
468                    ),
469                },
470                decision: None,
471                evidence_refs: record_ref.iter().cloned().collect(),
472            });
473        }
474        _ => {}
475    }
476    for c in &adopt_missing {
477        let path = &c.entry.destination;
478        preconditions.push(Precondition {
479            id: format!("destination-present:{path}"),
480            requirement: Requirement::Required,
481            evaluation: Evaluation::Unsatisfied {
482                reason: "expected and missing".to_owned(),
483            },
484            decision: None,
485            evidence_refs: observation
486                .refs
487                .destinations
488                .get(path)
489                .cloned()
490                .into_iter()
491                .collect(),
492        });
493    }
494    if let RecordRead::Invalid { reason } = &observation.record {
495        preconditions.push(Precondition {
496            id: "record-readable".into(),
497            requirement: Requirement::Required,
498            evaluation: Evaluation::Unsatisfied {
499                reason: reason.clone(),
500            },
501            decision: None,
502            evidence_refs: record_ref.iter().cloned().collect(),
503        });
504    } else if recorded.is_some() {
505        preconditions.push(Precondition {
506            id: "record-readable".into(),
507            requirement: Requirement::Required,
508            evaluation: Evaluation::Satisfied,
509            decision: None,
510            evidence_refs: record_ref.iter().cloned().collect(),
511        });
512    }
513    if let ConfigRead::Invalid { reason, .. } = &observation.config {
514        preconditions.push(Precondition {
515            id: "configuration-readable".into(),
516            requirement: Requirement::Required,
517            evaluation: Evaluation::Unsatisfied {
518                reason: reason.clone(),
519            },
520            decision: None,
521            evidence_refs: config_ref.iter().cloned().collect(),
522        });
523    }
524    if let Some(record) = recorded {
525        let newer =
526            manifest::alignment(&record.rk_version, engine_version) == Alignment::TargetNewer;
527        if newer {
528            findings.push(Finding {
529                code: "record-newer".into(),
530                detail: record.rk_version.clone(),
531            });
532        }
533        preconditions.push(Precondition {
534            id: "engine-not-older-than-record".into(),
535            requirement: Requirement::Required,
536            evaluation: if newer {
537                Evaluation::Unsatisfied {
538                    reason: format!(
539                        "the record came from rk {}, newer than this engine's {engine_version}; install release-kit {} or newer",
540                        record.rk_version, record.rk_version
541                    ),
542                }
543            } else {
544                Evaluation::Satisfied
545            },
546            decision: None,
547            evidence_refs: record_ref.iter().cloned().collect(),
548        });
549    }
550    let bundle_schema = candidate.manifest.payload_schema;
551    preconditions.push(Precondition {
552        id: "bundle-schema-readable".into(),
553        requirement: Requirement::Required,
554        evaluation: if bundle_schema <= PAYLOAD_SCHEMA {
555            Evaluation::Satisfied
556        } else {
557            Evaluation::Unsatisfied {
558                reason: format!(
559                    "the bundle declares payload schema {bundle_schema}, and this engine reads schema {PAYLOAD_SCHEMA} at most; install release-kit {candidate_version} or newer"
560                ),
561            }
562        },
563        decision: None,
564        evidence_refs: vec![candidate_ref.clone()],
565    });
566    if let Some(hooks_ref) = observation
567        .refs
568        .destinations
569        .get(landing::HOOKS_DESTINATION)
570        .cloned()
571    {
572        preconditions.push(Precondition {
573            id: "hooks-file-spliceable".into(),
574            requirement: Requirement::Required,
575            evaluation: observation
576                .hooks_defect
577                .as_ref()
578                .map_or(Evaluation::Satisfied, |defect| Evaluation::Unsatisfied {
579                    reason: defect.clone(),
580                }),
581            decision: None,
582            evidence_refs: vec![hooks_ref],
583        });
584    }
585    for c in compared.iter().filter(|c| c.conflict) {
586        let path = &c.entry.destination;
587        let mut refs: Vec<String> = observation
588            .refs
589            .destinations
590            .get(path)
591            .cloned()
592            .into_iter()
593            .collect();
594        refs.extend(record_ref.iter().cloned());
595        refs.extend(baseline_ref.iter().cloned());
596        preconditions.push(Precondition {
597            id: format!("owned-file-unedited:{path}"),
598            requirement: Requirement::Required,
599            evaluation: Evaluation::Unsatisfied {
600                reason: if c.missing {
601                    "the record names it and the disk does not hold it".to_owned()
602                } else if recorded.is_some() {
603                    "the target edited a file release-kit owns".to_owned()
604                } else {
605                    "the destination exists with different content".to_owned()
606                },
607            },
608            decision: None,
609            evidence_refs: refs,
610        });
611    }
612    let file_operations = operations
613        .iter()
614        .any(|operation| operation.path().is_some());
615    if recorded.is_some() {
616        let (requirement, evaluation, decision) = match &baseline_state {
617            BaselineState::NotObserved { reason } => {
618                let answered = selected.get("partial-baseline").map(String::as_str);
619                decisions.push(Decision {
620                    id: "partial-baseline".into(),
621                    question: "plan against the record's digests alone, with the recorded release's bytes unread?".into(),
622                    choices: vec![
623                        Choice {
624                            answer: "accept".into(),
625                            consequence: "the three-way comparison shows what diverged and cannot show the baseline it diverged from".into(),
626                        },
627                        Choice {
628                            answer: "fetch".into(),
629                            consequence: "re-plan with --fetch so the recorded release's bundle is read through the crates venue".into(),
630                        },
631                    ],
632                    selected: answered.map(str::to_owned),
633                });
634                (
635                    if file_operations {
636                        Requirement::DecisionRequired
637                    } else {
638                        Requirement::Advisory
639                    },
640                    if answered == Some("accept") {
641                        Evaluation::Satisfied
642                    } else {
643                        Evaluation::NotObserved {
644                            reason: reason.clone(),
645                        }
646                    },
647                    Some("partial-baseline".to_owned()),
648                )
649            }
650            _ => (Requirement::Advisory, Evaluation::Satisfied, None),
651        };
652        preconditions.push(Precondition {
653            id: "baseline-observed".into(),
654            requirement,
655            evaluation,
656            decision,
657            evidence_refs: baseline_ref.iter().cloned().collect(),
658        });
659    }
660    if recorded.is_none() && record_state == ClassifyRecord::Absent {
661        let source = resolution
662            .sources
663            .get("workflow")
664            .map_or("default", String::as_str);
665        let answered = (source != "default").then(|| {
666            resolution.params.as_ref().map_or_else(
667                || "worktree".to_owned(),
668                |p| p.workflow().as_str().to_owned(),
669            )
670        });
671        decisions.push(Decision {
672            id: "workflow-mode".into(),
673            question: "which working-copy mode does this project choose?".into(),
674            choices: vec![
675                Choice {
676                    answer: "worktree".into(),
677                    consequence: "every code-changing branch lives in a linked worktree and the main checkout commits nothing".into(),
678                },
679                Choice {
680                    answer: "branches".into(),
681                    consequence: "branches are worked in the main checkout, with worktrees optional beside it".into(),
682                },
683            ],
684            selected: answered.clone(),
685        });
686        preconditions.push(Precondition {
687            id: "workflow-mode-answered".into(),
688            requirement: Requirement::DecisionRequired,
689            evaluation: if answered.is_some() {
690                Evaluation::Satisfied
691            } else {
692                Evaluation::NotObserved {
693                    reason: "no flag, configuration, or decision names the mode".into(),
694                }
695            },
696            decision: Some("workflow-mode".into()),
697            evidence_refs: resolution_refs.clone(),
698        });
699    }
700    if let Some(record) = recorded {
701        if record.parameters.style.is_none() {
702            let source = resolution
703                .sources
704                .get("style")
705                .map_or("default", String::as_str);
706            let answered = (source != "default").then(|| {
707                resolution
708                    .params
709                    .as_ref()
710                    .and_then(landing::Params::style)
711                    .map_or_else(|| "trunk".to_owned(), |s| s.as_str().to_owned())
712            });
713            decisions.push(Decision {
714                id: "release-style".into(),
715                question: "the record predates the release style; which one does this project run?".into(),
716                choices: vec![
717                    Choice {
718                        answer: "trunk".into(),
719                        consequence: "the bot's release request is armed to merge itself".into(),
720                    },
721                    Choice {
722                        answer: "lines".into(),
723                        consequence: "every merge is a human's, and release lines carry the maintained versions".into(),
724                    },
725                ],
726                selected: answered.clone(),
727            });
728            preconditions.push(Precondition {
729                id: "release-style-answered".into(),
730                requirement: Requirement::DecisionRequired,
731                evaluation: if answered.is_some() {
732                    Evaluation::Satisfied
733                } else {
734                    Evaluation::NotObserved {
735                        reason: "neither the configuration nor a decision names the style".into(),
736                    }
737                },
738                decision: Some("release-style".into()),
739                evidence_refs: resolution_refs.clone(),
740            });
741        }
742    }
743    if recorded.is_none() && verdict == Verdict::NeedsDecision {
744        let answered = selected.get("release-activity").cloned();
745        decisions.push(Decision {
746            id: "release-activity".into(),
747            question: "tags or a second long-lived branch exist with no mechanism behind them; what are they?".into(),
748            choices: vec![
749                Choice {
750                    answer: "history".into(),
751                    consequence: "the activity is history the landing leaves in place, and the plan proceeds as a setup".into(),
752                },
753                Choice {
754                    answer: "migrate".into(),
755                    consequence: "another mechanism made them; follow the migration procedure and retire it first".into(),
756                },
757            ],
758            selected: answered.clone(),
759        });
760        preconditions.push(Precondition {
761            id: "release-activity-explained".into(),
762            requirement: Requirement::DecisionRequired,
763            evaluation: if answered.is_some() {
764                Evaluation::Satisfied
765            } else {
766                Evaluation::NotObserved {
767                    reason: "the operator has not said what the release activity is".into(),
768                }
769            },
770            decision: Some("release-activity".into()),
771            evidence_refs: observation.refs.repository.clone(),
772        });
773    }
774    preconditions.push(Precondition {
775        id: "forge-observed".into(),
776        requirement: Requirement::Advisory,
777        evaluation: match &observation.forge {
778            ForgeRead::Observed { .. } => Evaluation::Satisfied,
779            ForgeRead::NotObserved { reason } => Evaluation::NotObserved {
780                reason: reason.clone(),
781            },
782        },
783        decision: None,
784        evidence_refs: observation.refs.forge.clone(),
785    });
786    if let Some(reason) = flake_pin_behind {
787        preconditions.push(Precondition {
788            id: "pin-current".into(),
789            requirement: Requirement::Advisory,
790            evaluation: Evaluation::Unsatisfied { reason },
791            decision: None,
792            evidence_refs: observation.refs.pin.iter().cloned().collect(),
793        });
794    }
795    preconditions.push(Precondition {
796        id: "pin-wired".into(),
797        requirement: Requirement::Advisory,
798        evaluation: if observation.pin.is_some() {
799            Evaluation::Satisfied
800        } else {
801            Evaluation::NotObserved {
802                reason: "no manager file names release-kit".into(),
803            }
804        },
805        decision: None,
806        evidence_refs: observation.refs.pin.iter().cloned().collect(),
807    });
808    let readiness = readiness::derive(&preconditions);
809
810    // The postconditions, one per operation kind that leaves a check.
811    let mut postconditions: Vec<Postcondition> = Vec::new();
812    for operation in &operations {
813        match operation {
814            Operation::WriteFile { path, after, .. }
815            | Operation::SpliceBlock { path, after, .. } => {
816                postconditions.push(Postcondition::DestinationHolds {
817                    path: path.clone(),
818                    sha256: after.clone(),
819                });
820            }
821            Operation::WriteRecord { after, .. } => {
822                postconditions.push(Postcondition::RecordReadsBack {
823                    sha256: after.clone(),
824                });
825            }
826            Operation::UpdatePin { manager, after, .. } => {
827                postconditions.push(Postcondition::PinReads {
828                    manager: manager.clone(),
829                    version: after.clone(),
830                });
831            }
832            Operation::RemoveOwnedFile { .. } => {}
833        }
834    }
835    // The standing verifier runs last and its answer is reported: it
836    // judges the whole target, sentinels the operator still owes included,
837    // so it names what is short rather than failing the apply.
838    if !operations.is_empty() {
839        postconditions.push(Postcondition::StatusCheckClean);
840    }
841
842    // The sections, assembled.
843    let configuration = resolution.params.as_ref().map(|params| Configuration {
844        tech: params.tech().to_owned(),
845        forge: params.forge().to_owned(),
846        repo: params.repo().to_owned(),
847        workflow: params.workflow().as_str().to_owned(),
848        style: params.style().map(|style| style.as_str().to_owned()),
849        nix: params.nix(),
850        trunk: params.trunk().to_owned(),
851        line_prefix: params.line_prefix().to_owned(),
852        security_contact: params.security_contact().to_owned(),
853        security_response: params.security_response().to_owned(),
854        sources: resolution.sources.clone(),
855        evidence_refs: resolution_refs.clone(),
856    });
857    let record_view = match &observation.record {
858        RecordRead::Absent => RecordState::Absent,
859        RecordRead::Present { manifest, bytes } => RecordState::Present {
860            rk_version: manifest.rk_version.clone(),
861            payload_sha256: manifest.payload_sha256.clone(),
862            schema_version: manifest.schema_version,
863            origin: manifest.origin.clone(),
864            sha256: Digest::of(bytes),
865        },
866        RecordRead::Invalid { reason } => RecordState::Invalid {
867            reason: reason.clone(),
868        },
869    };
870    let configuration_view = match &observation.config {
871        ConfigRead::Absent => ConfigurationState {
872            present: false,
873            sha256: None,
874            invalid: None,
875            pending: Vec::new(),
876        },
877        ConfigRead::Present { config, bytes } => ConfigurationState {
878            present: true,
879            sha256: Some(Digest::of(bytes)),
880            invalid: None,
881            pending: recorded
882                .map_or_else(Vec::new, |record| crate::config::pending(config, record)),
883        },
884        ConfigRead::Invalid { reason, bytes } => ConfigurationState {
885            present: true,
886            sha256: Some(Digest::of(bytes)),
887            invalid: Some(reason.clone()),
888            pending: Vec::new(),
889        },
890    };
891    let mut destination_paths: Vec<String> = entries
892        .iter()
893        .map(|entry| entry.destination.clone())
894        .chain(
895            recorded
896                .into_iter()
897                .flat_map(|record| record.files.iter().map(|file| file.destination.clone())),
898        )
899        .collect();
900    destination_paths.sort();
901    destination_paths.dedup();
902    let destinations: Vec<Destination> = destination_paths
903        .into_iter()
904        .map(|path| {
905            let bytes = observation.files.get(&path);
906            Destination {
907                present: bytes.is_some(),
908                sha256: bytes.map(|bytes| Digest::of(bytes)),
909                recorded_kind: recorded
910                    .and_then(|record| record.file(&path))
911                    .map(|file| file.kind),
912                path,
913            }
914        })
915        .collect();
916    let installation_refs: Vec<String> = record_ref
917        .iter()
918        .chain(config_ref.iter())
919        .cloned()
920        .chain(observation.refs.destinations.values().cloned())
921        .collect();
922    let forge_view = match &observation.forge {
923        ForgeRead::NotObserved { reason } => ForgeState::NotObserved {
924            reason: reason.clone(),
925        },
926        ForgeRead::Observed { trunk, remote_tip } => ForgeState::Observed {
927            trunk: trunk.clone(),
928            remote_tip: remote_tip.clone(),
929            evidence_refs: observation.refs.forge.clone(),
930        },
931    };
932    let mut plan = Plan {
933        schema: PLAN_SCHEMA.into(),
934        identity: Identity {
935            plan_id: String::new(),
936            created_at: clock.to_owned(),
937            engine_version: engine_version.to_owned(),
938        },
939        classification,
940        findings,
941        desired_state: DesiredState {
942            intent,
943            selector: selector.to_owned(),
944            release: ResolvedRelease {
945                version: candidate_version.clone(),
946                venue: candidate.venue.to_owned(),
947                payload_sha256: candidate.manifest.payload_sha256.clone(),
948                payload_schema: bundle_schema,
949            },
950            configuration,
951            unresolved: resolution.unresolved.clone(),
952        },
953        observed_state: ObservedState {
954            repository: Repository {
955                target: observation.target.clone(),
956                git: observation.git,
957                tags: observation.facts.tags,
958                long_lived_branches: observation.facts.long_lived_branches.clone(),
959                release_markers: observation.facts.release_markers.clone(),
960                collisions: observation.facts.collisions.clone(),
961                tech: observation.tech.clone(),
962                forge: observation.forge_name.clone(),
963                repo: observation.repo.clone(),
964                verdict,
965                evidence_refs: observation.refs.repository.clone(),
966            },
967            installation: Installation {
968                record: record_view,
969                configuration: configuration_view,
970                destinations,
971                evidence_refs: installation_refs,
972            },
973            host: Host {
974                engine_version: engine_version.to_owned(),
975                pin: observation.pin.clone(),
976                evidence_refs: observation
977                    .refs
978                    .host
979                    .iter()
980                    .chain(observation.refs.pin.iter())
981                    .cloned()
982                    .collect(),
983            },
984            forge: forge_view,
985        },
986        release: Release {
987            candidate: BundleIdentity {
988                version: candidate_version,
989                payload_sha256: candidate.manifest.payload_sha256.clone(),
990                payload_schema: bundle_schema,
991                artifacts: candidate.manifest.artifacts.len(),
992                evidence_refs: vec![candidate_ref],
993            },
994            verification: candidate.verification,
995            baseline: baseline_state,
996            compatibility: Compatibility {
997                engine_schema: PAYLOAD_SCHEMA,
998                bundle_schema,
999                readable: bundle_schema <= PAYLOAD_SCHEMA,
1000            },
1001            guidance: Guidance {
1002                coverage: "not-shipped".into(),
1003            },
1004        },
1005        operations,
1006        preconditions,
1007        decisions,
1008        postconditions,
1009        evidence: observation.ledger.into_items(),
1010        readiness,
1011        input_fingerprint: Digest::of(b""),
1012    };
1013    plan.input_fingerprint = fingerprint::compute(&plan);
1014    plan.identity.plan_id = fingerprint::plan_id(&plan.input_fingerprint, clock);
1015    let withheld = resolution
1016        .nix_withheld
1017        .as_ref()
1018        .map(|(set, reason)| {
1019            set.iter()
1020                .map(|path| landing::Withheld {
1021                    path: path.clone(),
1022                    reason: reason.clone(),
1023                })
1024                .collect()
1025        })
1026        .unwrap_or_default();
1027    Ok(Planned {
1028        plan,
1029        blobs,
1030        outcomes,
1031        config,
1032        withheld,
1033    })
1034}
1035
1036/// The word the fronts print for one compared destination.
1037fn disposition(
1038    c: &Compared<'_>,
1039    recorded: Option<&Manifest>,
1040    disk: Option<&Vec<u8>>,
1041) -> Disposition {
1042    if c.conflict {
1043        return if c.missing {
1044            Disposition::Missing
1045        } else {
1046            Disposition::Conflict
1047        };
1048    }
1049    if c.write {
1050        return Disposition::Write;
1051    }
1052    let named = recorded.and_then(|record| record.file(&c.entry.destination));
1053    match (named, c.entry.kind) {
1054        (Some(_), Kind::Rendered) => Disposition::Unchanged,
1055        (Some(_), Kind::Seeded) => {
1056            let at_baseline = disk
1057                .map(|bytes| Digest::of(bytes))
1058                .is_some_and(|digest| Some(&digest) == c.record.baseline_sha256.as_ref());
1059            if at_baseline {
1060                Disposition::Unchanged
1061            } else {
1062                Disposition::Drift
1063            }
1064        }
1065        (Some(_), Kind::State) => Disposition::State,
1066        (None, _) => {
1067            if disk.is_some_and(|bytes| *bytes == c.entry.rendered) {
1068                Disposition::Unchanged
1069            } else {
1070                Disposition::Kept
1071            }
1072        }
1073    }
1074}
1075
1076/// The comparison's outcome for one destination.
1077struct Outcome {
1078    write: bool,
1079    conflict: bool,
1080    missing: bool,
1081    record: FileRecord,
1082}
1083
1084/// A destination on a target with no record: it lands as `rk init`
1085/// lands it. A differing `rendered` destination is a conflict, a
1086/// differing `seeded` or `state` one is the target's and is kept.
1087fn compare_fresh(entry: &Entry, disk: Option<&[u8]>) -> Outcome {
1088    let (write, conflict, landed) = match disk {
1089        None => (true, false, entry.rendered.clone()),
1090        Some(bytes) if bytes == entry.rendered => (false, false, bytes.to_vec()),
1091        Some(bytes) if entry.kind != Kind::Rendered => (false, false, bytes.to_vec()),
1092        Some(_) => (false, true, entry.rendered.clone()),
1093    };
1094    Outcome {
1095        write,
1096        conflict,
1097        missing: false,
1098        record: FileRecord {
1099            destination: entry.destination.clone(),
1100            kind: entry.kind,
1101            sha256: Digest::of(&landed),
1102            baseline_sha256: baseline_digest(entry),
1103        },
1104    }
1105}
1106
1107/// A destination on a recorded target: the three digests decide it, in
1108/// the shape `rk upgrade` has always decided by.
1109fn compare_recorded(entry: &Entry, recorded: Option<&FileRecord>, disk: Option<&[u8]>) -> Outcome {
1110    let Some(recorded) = recorded else {
1111        return compare_fresh(entry, disk);
1112    };
1113    let candidate_record = |sha256: Digest| FileRecord {
1114        destination: entry.destination.clone(),
1115        kind: entry.kind,
1116        sha256,
1117        baseline_sha256: baseline_digest(entry),
1118    };
1119    // A seeded file this payload reclassifies as rendered claims ownership
1120    // of a file the target may have tuned; only untouched bytes permit it.
1121    if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
1122        let untouched =
1123            disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
1124        return Outcome {
1125            write: untouched,
1126            conflict: !untouched,
1127            missing: disk.is_none(),
1128            record: candidate_record(Digest::of(&entry.rendered)),
1129        };
1130    }
1131    match entry.kind {
1132        Kind::Rendered => match disk {
1133            Some(bytes) if Digest::of(bytes) == recorded.sha256 => Outcome {
1134                write: bytes != entry.rendered,
1135                conflict: false,
1136                missing: false,
1137                record: candidate_record(Digest::of(&entry.rendered)),
1138            },
1139            Some(bytes) if bytes == entry.rendered => Outcome {
1140                write: false,
1141                conflict: false,
1142                missing: false,
1143                record: candidate_record(Digest::of(&entry.rendered)),
1144            },
1145            other => Outcome {
1146                write: false,
1147                conflict: true,
1148                missing: other.is_none(),
1149                record: candidate_record(Digest::of(&entry.rendered)),
1150            },
1151        },
1152        Kind::Seeded => {
1153            // Never written; the record follows the target's bytes and
1154            // keeps the baseline it tunes away from.
1155            let baseline = if recorded.kind == Kind::Rendered {
1156                Some(recorded.sha256.clone())
1157            } else {
1158                recorded.baseline_sha256.clone()
1159            };
1160            let sha256 = disk.map_or_else(|| recorded.sha256.clone(), Digest::of);
1161            Outcome {
1162                write: false,
1163                conflict: false,
1164                missing: false,
1165                record: FileRecord {
1166                    destination: entry.destination.clone(),
1167                    kind: entry.kind,
1168                    sha256,
1169                    baseline_sha256: baseline,
1170                },
1171            }
1172        }
1173        Kind::State => Outcome {
1174            write: false,
1175            conflict: false,
1176            missing: false,
1177            record: FileRecord {
1178                destination: entry.destination.clone(),
1179                kind: entry.kind,
1180                sha256: recorded.sha256.clone(),
1181                baseline_sha256: None,
1182            },
1183        },
1184    }
1185}
1186
1187/// The digest a fresh record keeps as a destination's baseline.
1188fn baseline_digest(entry: &Entry) -> Option<Digest> {
1189    match entry.kind {
1190        Kind::State => None,
1191        Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
1192    }
1193}
1194
1195/// The recorded release's bytes for one destination, where the baseline
1196/// bundle carries the artifact the record's baseline digest names.
1197fn baseline_bytes(source: &dyn ReleaseSource, record: &Manifest, entry: &Entry) -> Option<Vec<u8>> {
1198    let digest = record.file(&entry.destination)?.baseline_sha256.as_ref()?;
1199    source.blob(digest).ok()
1200}
1201
1202/// The record an apply writes: the candidate's identity, the resolved
1203/// parameters, every compared destination, and the candidate's pins,
1204/// with the first landing's instant and origin preserved where a record
1205/// exists.
1206fn planned_manifest<'a>(
1207    candidate: &Candidate<'_>,
1208    params: &landing::Params,
1209    recorded: Option<&Manifest>,
1210    intent: Intent,
1211    clock: &str,
1212    files: impl Iterator<Item = &'a FileRecord>,
1213) -> Manifest {
1214    let pins = crate::release::read(candidate.source, &candidate.manifest, "versions.toml")
1215        .map(|bytes| crate::registry::pins_in(&String::from_utf8_lossy(&bytes)))
1216        .unwrap_or_default()
1217        .into_iter()
1218        .filter(|pin| pin.used_by.iter().any(|user| user == params.tech()))
1219        .map(|pin| (pin.name, pin.version))
1220        .collect();
1221    Manifest {
1222        schema_version: manifest::SCHEMA_VERSION,
1223        rk_version: candidate.manifest.release_kit_version.clone(),
1224        payload_sha256: candidate.manifest.payload_sha256.clone(),
1225        origin: recorded.map_or_else(
1226            || {
1227                if intent == Intent::Adopt {
1228                    "adopt".to_owned()
1229                } else {
1230                    "init".to_owned()
1231                }
1232            },
1233            |record| record.origin.clone(),
1234        ),
1235        tech: params.tech().to_owned(),
1236        forge: params.forge().to_owned(),
1237        landed_at: recorded.map_or_else(|| clock.to_owned(), |record| record.landed_at.clone()),
1238        parameters: Parameters {
1239            repo: params.repo().to_owned(),
1240            workflow: params.workflow(),
1241            style: params.style(),
1242            nix: params.nix(),
1243            trunk: params.trunk().to_owned(),
1244            line_prefix: params.line_prefix().to_owned(),
1245            security_contact: params.security_contact().to_owned(),
1246            security_response: params.security_response().to_owned(),
1247        },
1248        files: files
1249            .map(|file| FileRecord {
1250                destination: file.destination.clone(),
1251                kind: file.kind,
1252                sha256: file.sha256.clone(),
1253                baseline_sha256: file.baseline_sha256.clone(),
1254            })
1255            .collect(),
1256        pins,
1257    }
1258}
1259
1260/// A version with its leading `v` removed, so a manager's recorded form
1261/// compares against the crate's.
1262fn trim_v(version: &str) -> &str {
1263    version.strip_prefix('v').unwrap_or(version)
1264}