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, ForgeState, Guidance, Host, Identity, Installation,
25    ObservedState, PLAN_SCHEMA, Plan, Planned, Postcondition, RecordState, Release, Repository,
26    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    /// The instant the plan is computed, RFC 3339.
64    pub clock: &'a str,
65    /// The engine computing it.
66    pub engine_version: &'a str,
67    /// The selector as given.
68    pub selector: &'a str,
69    /// The candidate bundle.
70    pub candidate: Candidate<'a>,
71    /// The recorded release's bundle.
72    pub baseline: Baseline<'a>,
73    /// What was observed at the target.
74    pub observation: Observation,
75    /// The landing parameters, resolved or not.
76    pub resolution: Resolution,
77    /// The decisions the operator selected, by id.
78    pub selected: &'a BTreeMap<String, String>,
79}
80
81/// What the three-way comparison decided for one destination.
82struct Compared<'a> {
83    entry: &'a Entry,
84    /// The digest the destination holds now, or absent.
85    before: Option<Digest>,
86    /// Whether the candidate's bytes are written.
87    write: bool,
88    /// Whether the target edited a file release-kit owns.
89    conflict: bool,
90    /// Whether a rendered file the record names is missing from the disk.
91    missing: bool,
92    /// The record entry after the apply.
93    record: FileRecord,
94}
95
96/// Compute the plan.
97///
98/// # Errors
99///
100/// Returns the seam's own failures where a bundle cannot be read, and a
101/// serialization failure for the planned record, which is a defect.
102#[allow(
103    clippy::too_many_lines,
104    reason = "one plan is one linear derivation from observation to fingerprint, and cutting it would separate a section from the inputs it cites"
105)]
106pub fn plan(inputs: Inputs<'_>) -> Result<Planned, RkError> {
107    let Inputs {
108        clock,
109        engine_version,
110        selector,
111        candidate,
112        baseline,
113        mut observation,
114        resolution,
115        selected,
116    } = inputs;
117    let mut blobs: BTreeMap<Digest, Vec<u8>> = BTreeMap::new();
118    let mut findings: Vec<Finding> = Vec::new();
119    let mut preconditions: Vec<Precondition> = Vec::new();
120    let mut decisions: Vec<Decision> = Vec::new();
121
122    // The candidate, cited by everything derived from it.
123    let candidate_ref = observation.ledger.observe(
124        "candidate-bundle",
125        EvidenceKind::Bundle,
126        candidate.venue,
127        clock,
128        Some(candidate.manifest.payload_sha256.clone()),
129        format!("manifest through the {} source", candidate.venue),
130    );
131    let baseline_state = match &baseline {
132        Baseline::NotNeeded => BaselineState::NotNeeded,
133        Baseline::Embedded(_) => BaselineState::Embedded,
134        Baseline::Cached { version, .. } => BaselineState::Cached {
135            version: version.clone(),
136        },
137        Baseline::NotObserved { reason } => BaselineState::NotObserved {
138            reason: reason.clone(),
139        },
140    };
141    let baseline_source: Option<&dyn ReleaseSource> = match &baseline {
142        Baseline::Embedded(source) | Baseline::Cached { source, .. } => Some(*source),
143        Baseline::NotNeeded | Baseline::NotObserved { .. } => None,
144    };
145    let baseline_ref = baseline_source.map(|source| {
146        let digest = source
147            .manifest()
148            .ok()
149            .map(|manifest| manifest.payload_sha256);
150        observation.ledger.observe(
151            "baseline-bundle",
152            EvidenceKind::Bundle,
153            "recorded release",
154            clock,
155            digest,
156            "manifest through the seam",
157        )
158    });
159
160    // The verdict and the record state.
161    let verdict = classify::verdict(&observation.facts);
162    let record_state = match &observation.record {
163        RecordRead::Absent => ClassifyRecord::Absent,
164        RecordRead::Present { .. } => ClassifyRecord::Present,
165        RecordRead::Invalid { .. } => ClassifyRecord::Invalid,
166    };
167    let recorded: Option<&Manifest> = match &observation.record {
168        RecordRead::Present { manifest, .. } => Some(manifest),
169        RecordRead::Absent | RecordRead::Invalid { .. } => None,
170    };
171    if recorded.is_none() {
172        for marker in &observation.facts.release_markers {
173            findings.push(Finding {
174                code: "release-marker",
175                detail: marker.clone(),
176            });
177        }
178        for collision in &observation.facts.collisions {
179            findings.push(Finding {
180                code: "payload-collision",
181                detail: collision.clone(),
182            });
183        }
184        if observation.facts.tags > 0 {
185            findings.push(Finding {
186                code: "tag",
187                detail: format!(
188                    "{} tags with no mechanism behind them",
189                    observation.facts.tags
190                ),
191            });
192        }
193        for branch in &observation.facts.long_lived_branches {
194            findings.push(Finding {
195                code: "long-lived-branch",
196                detail: branch.clone(),
197            });
198        }
199    }
200    if let RecordRead::Invalid { reason } = &observation.record {
201        findings.push(Finding {
202            code: "record-invalid",
203            detail: reason.clone(),
204        });
205    }
206
207    // The projection under the resolved parameters, where they resolved.
208    let mut entries: Vec<Entry> = Vec::new();
209    if let Some(params) = &resolution.params {
210        entries = landing::projection(candidate.source, params)?;
211        if let Some((set, _)) = &resolution.nix_withheld {
212            entries.retain(|entry| !set.iter().any(|path| path == &entry.destination));
213        }
214    }
215
216    // The three-way comparison, in the one-pass shape the landing rules
217    // bind: every conflict collected, nothing refused one at a time.
218    let mut compared: Vec<Compared<'_>> = Vec::new();
219    for entry in &entries {
220        let disk = observation.files.get(&entry.destination).map(Vec::as_slice);
221        let before = disk.map(Digest::of);
222        let comparison = recorded.map_or_else(
223            || compare_fresh(entry, disk),
224            |record| compare_recorded(entry, record.file(&entry.destination), disk),
225        );
226        if comparison.write {
227            blobs.insert(Digest::of(&entry.rendered), entry.rendered.clone());
228            if let Some(bytes) = disk {
229                blobs.insert(Digest::of(bytes), bytes.to_vec());
230            }
231        }
232        if comparison.conflict {
233            if let Some(bytes) = disk {
234                blobs.insert(Digest::of(bytes), bytes.to_vec());
235            }
236            blobs.insert(Digest::of(&entry.rendered), entry.rendered.clone());
237            if let (Some(source), Some(record)) = (baseline_source, recorded) {
238                if let Some(baseline_bytes) = baseline_bytes(source, record, entry) {
239                    blobs.insert(Digest::of(&baseline_bytes), baseline_bytes);
240                }
241            }
242        }
243        compared.push(Compared {
244            entry,
245            before,
246            write: comparison.write,
247            conflict: comparison.conflict,
248            missing: comparison.missing,
249            record: comparison.record,
250        });
251    }
252    let owned_drift = compared.iter().any(|c| c.conflict) || observation.hooks_defect.is_some();
253    for c in compared.iter().filter(|c| c.conflict) {
254        findings.push(Finding {
255            code: if c.missing {
256                "owned-missing"
257            } else {
258                "owned-drift"
259            },
260            detail: c.entry.destination.clone(),
261        });
262    }
263    if let Some(defect) = &observation.hooks_defect {
264        findings.push(Finding {
265            code: "owned-drift",
266            detail: defect.clone(),
267        });
268    }
269    let classification = classify::classify(record_state, verdict, owned_drift);
270
271    // The operations, in apply order: files, then the pin, then the
272    // record, last.
273    let mut operations: Vec<Operation> = Vec::new();
274    for c in compared.iter().filter(|c| c.write && !c.conflict) {
275        let after = Digest::of(&c.entry.rendered);
276        operations.push(match c.entry.placement {
277            Placement::Whole => Operation::WriteFile {
278                path: c.entry.destination.clone(),
279                kind: c.entry.kind,
280                before: c.before.clone(),
281                after,
282            },
283            Placement::Block => Operation::SpliceBlock {
284                path: c.entry.destination.clone(),
285                marker: landing::block_markers(&c.entry.destination)
286                    .map_or("", |(begin, _)| begin)
287                    .to_owned(),
288                before: c.before.clone(),
289                after,
290            },
291        });
292    }
293    let candidate_version = candidate.manifest.release_kit_version.clone();
294    if let Some(pin) = &observation.pin {
295        if trim_v(&pin.version) != trim_v(&candidate_version) {
296            operations.push(Operation::UpdatePin {
297                manager: pin.manager.clone(),
298                before: pin.version.clone(),
299                after: candidate_version.clone(),
300            });
301        }
302    }
303    let planned_record = match (&resolution.params, record_state) {
304        (Some(params), ClassifyRecord::Absent | ClassifyRecord::Present) if !owned_drift => {
305            let record = planned_manifest(
306                &candidate,
307                params,
308                recorded,
309                clock,
310                compared.iter().map(|c| &c.record),
311            );
312            let bytes = manifest::render(&record)?;
313            let after = Digest::of(&bytes);
314            let before = match &observation.record {
315                RecordRead::Present { bytes, .. } => Some(Digest::of(bytes)),
316                RecordRead::Absent | RecordRead::Invalid { .. } => None,
317            };
318            if before.as_ref() == Some(&after) {
319                None
320            } else {
321                blobs.insert(after.clone(), bytes);
322                if let RecordRead::Present { bytes, .. } = &observation.record {
323                    blobs.insert(Digest::of(bytes), bytes.clone());
324                }
325                Some(Operation::WriteRecord { before, after })
326            }
327        }
328        _ => None,
329    };
330    if let Some(operation) = planned_record {
331        operations.push(operation);
332    }
333
334    // The preconditions, each with its requirement.
335    let record_ref = observation.refs.record.clone();
336    let config_ref = observation.refs.configuration.clone();
337    let resolution_refs: Vec<String> = record_ref
338        .iter()
339        .chain(config_ref.iter())
340        .chain(observation.refs.repository.iter())
341        .cloned()
342        .collect();
343    preconditions.push(Precondition {
344        id: "technology-resolved".into(),
345        requirement: Requirement::Required,
346        evaluation: resolution
347            .unresolved
348            .as_ref()
349            .map_or(Evaluation::Satisfied, |reason| Evaluation::Unsatisfied {
350                reason: reason.clone(),
351            }),
352        decision: None,
353        evidence_refs: resolution_refs.clone(),
354    });
355    if let RecordRead::Invalid { reason } = &observation.record {
356        preconditions.push(Precondition {
357            id: "record-readable".into(),
358            requirement: Requirement::Required,
359            evaluation: Evaluation::Unsatisfied {
360                reason: reason.clone(),
361            },
362            decision: None,
363            evidence_refs: record_ref.iter().cloned().collect(),
364        });
365    } else if recorded.is_some() {
366        preconditions.push(Precondition {
367            id: "record-readable".into(),
368            requirement: Requirement::Required,
369            evaluation: Evaluation::Satisfied,
370            decision: None,
371            evidence_refs: record_ref.iter().cloned().collect(),
372        });
373    }
374    if let ConfigRead::Invalid { reason, .. } = &observation.config {
375        preconditions.push(Precondition {
376            id: "configuration-readable".into(),
377            requirement: Requirement::Required,
378            evaluation: Evaluation::Unsatisfied {
379                reason: reason.clone(),
380            },
381            decision: None,
382            evidence_refs: config_ref.iter().cloned().collect(),
383        });
384    }
385    if let Some(record) = recorded {
386        let newer =
387            manifest::alignment(&record.rk_version, engine_version) == Alignment::TargetNewer;
388        if newer {
389            findings.push(Finding {
390                code: "record-newer",
391                detail: record.rk_version.clone(),
392            });
393        }
394        preconditions.push(Precondition {
395            id: "engine-not-older-than-record".into(),
396            requirement: Requirement::Required,
397            evaluation: if newer {
398                Evaluation::Unsatisfied {
399                    reason: format!(
400                        "the record came from rk {}, newer than this engine's {engine_version}; install release-kit {} or newer",
401                        record.rk_version, record.rk_version
402                    ),
403                }
404            } else {
405                Evaluation::Satisfied
406            },
407            decision: None,
408            evidence_refs: record_ref.iter().cloned().collect(),
409        });
410    }
411    let bundle_schema = candidate.manifest.payload_schema;
412    preconditions.push(Precondition {
413        id: "bundle-schema-readable".into(),
414        requirement: Requirement::Required,
415        evaluation: if bundle_schema <= PAYLOAD_SCHEMA {
416            Evaluation::Satisfied
417        } else {
418            Evaluation::Unsatisfied {
419                reason: format!(
420                    "the bundle declares payload schema {bundle_schema}, and this engine reads schema {PAYLOAD_SCHEMA} at most; install release-kit {candidate_version} or newer"
421                ),
422            }
423        },
424        decision: None,
425        evidence_refs: vec![candidate_ref.clone()],
426    });
427    if let Some(hooks_ref) = observation
428        .refs
429        .destinations
430        .get(landing::HOOKS_DESTINATION)
431        .cloned()
432    {
433        preconditions.push(Precondition {
434            id: "hooks-file-spliceable".into(),
435            requirement: Requirement::Required,
436            evaluation: observation
437                .hooks_defect
438                .as_ref()
439                .map_or(Evaluation::Satisfied, |defect| Evaluation::Unsatisfied {
440                    reason: defect.clone(),
441                }),
442            decision: None,
443            evidence_refs: vec![hooks_ref],
444        });
445    }
446    for c in compared.iter().filter(|c| c.conflict) {
447        let path = &c.entry.destination;
448        let mut refs: Vec<String> = observation
449            .refs
450            .destinations
451            .get(path)
452            .cloned()
453            .into_iter()
454            .collect();
455        refs.extend(record_ref.iter().cloned());
456        refs.extend(baseline_ref.iter().cloned());
457        preconditions.push(Precondition {
458            id: format!("owned-file-unedited:{path}"),
459            requirement: Requirement::Required,
460            evaluation: Evaluation::Unsatisfied {
461                reason: if c.missing {
462                    "the record names it and the disk does not hold it".to_owned()
463                } else if recorded.is_some() {
464                    "the target edited a file release-kit owns".to_owned()
465                } else {
466                    "the destination exists with different content".to_owned()
467                },
468            },
469            decision: None,
470            evidence_refs: refs,
471        });
472    }
473    let file_operations = operations
474        .iter()
475        .any(|operation| operation.path().is_some());
476    if recorded.is_some() {
477        let (requirement, evaluation, decision) = match &baseline_state {
478            BaselineState::NotObserved { reason } => {
479                let answered = selected.get("partial-baseline").map(String::as_str);
480                decisions.push(Decision {
481                    id: "partial-baseline".into(),
482                    question: "plan against the record's digests alone, with the recorded release's bytes unread?".into(),
483                    choices: vec![
484                        Choice {
485                            answer: "accept".into(),
486                            consequence: "the three-way comparison shows what diverged and cannot show the baseline it diverged from".into(),
487                        },
488                        Choice {
489                            answer: "fetch".into(),
490                            consequence: "re-plan with --fetch so the recorded release's bundle is read through the crates venue".into(),
491                        },
492                    ],
493                    selected: answered.map(str::to_owned),
494                });
495                (
496                    if file_operations {
497                        Requirement::DecisionRequired
498                    } else {
499                        Requirement::Advisory
500                    },
501                    if answered == Some("accept") {
502                        Evaluation::Satisfied
503                    } else {
504                        Evaluation::NotObserved {
505                            reason: reason.clone(),
506                        }
507                    },
508                    Some("partial-baseline".to_owned()),
509                )
510            }
511            _ => (Requirement::Advisory, Evaluation::Satisfied, None),
512        };
513        preconditions.push(Precondition {
514            id: "baseline-observed".into(),
515            requirement,
516            evaluation,
517            decision,
518            evidence_refs: baseline_ref.iter().cloned().collect(),
519        });
520    }
521    if recorded.is_none() && record_state == ClassifyRecord::Absent {
522        let source = resolution
523            .sources
524            .get("workflow")
525            .map_or("default", String::as_str);
526        let answered = (source != "default").then(|| {
527            resolution.params.as_ref().map_or_else(
528                || "worktree".to_owned(),
529                |p| p.workflow().as_str().to_owned(),
530            )
531        });
532        decisions.push(Decision {
533            id: "workflow-mode".into(),
534            question: "which working-copy mode does this project choose?".into(),
535            choices: vec![
536                Choice {
537                    answer: "worktree".into(),
538                    consequence: "every code-changing branch lives in a linked worktree and the main checkout commits nothing".into(),
539                },
540                Choice {
541                    answer: "branches".into(),
542                    consequence: "branches are worked in the main checkout, with worktrees optional beside it".into(),
543                },
544            ],
545            selected: answered.clone(),
546        });
547        preconditions.push(Precondition {
548            id: "workflow-mode-answered".into(),
549            requirement: Requirement::DecisionRequired,
550            evaluation: if answered.is_some() {
551                Evaluation::Satisfied
552            } else {
553                Evaluation::NotObserved {
554                    reason: "no flag, configuration, or decision names the mode".into(),
555                }
556            },
557            decision: Some("workflow-mode".into()),
558            evidence_refs: resolution_refs.clone(),
559        });
560    }
561    if let Some(record) = recorded {
562        if record.parameters.style.is_none() {
563            let source = resolution
564                .sources
565                .get("style")
566                .map_or("default", String::as_str);
567            let answered = (source != "default").then(|| {
568                resolution
569                    .params
570                    .as_ref()
571                    .and_then(landing::Params::style)
572                    .map_or_else(|| "trunk".to_owned(), |s| s.as_str().to_owned())
573            });
574            decisions.push(Decision {
575                id: "release-style".into(),
576                question: "the record predates the release style; which one does this project run?".into(),
577                choices: vec![
578                    Choice {
579                        answer: "trunk".into(),
580                        consequence: "the bot's release request is armed to merge itself".into(),
581                    },
582                    Choice {
583                        answer: "lines".into(),
584                        consequence: "every merge is a human's, and release lines carry the maintained versions".into(),
585                    },
586                ],
587                selected: answered.clone(),
588            });
589            preconditions.push(Precondition {
590                id: "release-style-answered".into(),
591                requirement: Requirement::DecisionRequired,
592                evaluation: if answered.is_some() {
593                    Evaluation::Satisfied
594                } else {
595                    Evaluation::NotObserved {
596                        reason: "neither the configuration nor a decision names the style".into(),
597                    }
598                },
599                decision: Some("release-style".into()),
600                evidence_refs: resolution_refs.clone(),
601            });
602        }
603    }
604    if recorded.is_none() && verdict == Verdict::NeedsDecision {
605        let answered = selected.get("release-activity").cloned();
606        decisions.push(Decision {
607            id: "release-activity".into(),
608            question: "tags or a second long-lived branch exist with no mechanism behind them; what are they?".into(),
609            choices: vec![
610                Choice {
611                    answer: "history".into(),
612                    consequence: "the activity is history the landing leaves in place, and the plan proceeds as a setup".into(),
613                },
614                Choice {
615                    answer: "migrate".into(),
616                    consequence: "another mechanism made them; follow the migration procedure and retire it first".into(),
617                },
618            ],
619            selected: answered.clone(),
620        });
621        preconditions.push(Precondition {
622            id: "release-activity-explained".into(),
623            requirement: Requirement::DecisionRequired,
624            evaluation: if answered.is_some() {
625                Evaluation::Satisfied
626            } else {
627                Evaluation::NotObserved {
628                    reason: "the operator has not said what the release activity is".into(),
629                }
630            },
631            decision: Some("release-activity".into()),
632            evidence_refs: observation.refs.repository.clone(),
633        });
634    }
635    preconditions.push(Precondition {
636        id: "forge-observed".into(),
637        requirement: Requirement::Advisory,
638        evaluation: match &observation.forge {
639            ForgeRead::Observed { .. } => Evaluation::Satisfied,
640            ForgeRead::NotObserved { reason } => Evaluation::NotObserved {
641                reason: reason.clone(),
642            },
643        },
644        decision: None,
645        evidence_refs: observation.refs.forge.clone(),
646    });
647    preconditions.push(Precondition {
648        id: "pin-wired".into(),
649        requirement: Requirement::Advisory,
650        evaluation: if observation.pin.is_some() {
651            Evaluation::Satisfied
652        } else {
653            Evaluation::NotObserved {
654                reason: "no manager file names release-kit".into(),
655            }
656        },
657        decision: None,
658        evidence_refs: observation.refs.pin.iter().cloned().collect(),
659    });
660    let readiness = readiness::derive(&preconditions);
661
662    // The postconditions, one per operation kind that leaves a check.
663    let mut postconditions: Vec<Postcondition> = Vec::new();
664    for operation in &operations {
665        match operation {
666            Operation::WriteFile { path, after, .. }
667            | Operation::SpliceBlock { path, after, .. } => {
668                postconditions.push(Postcondition::DestinationHolds {
669                    path: path.clone(),
670                    sha256: after.clone(),
671                });
672            }
673            Operation::WriteRecord { after, .. } => {
674                postconditions.push(Postcondition::RecordReadsBack {
675                    sha256: after.clone(),
676                });
677            }
678            Operation::UpdatePin { manager, after, .. } => {
679                postconditions.push(Postcondition::PinReads {
680                    manager: manager.clone(),
681                    version: after.clone(),
682                });
683            }
684            Operation::RemoveOwnedFile { .. } => {}
685        }
686    }
687    if !operations.is_empty() {
688        postconditions.push(Postcondition::StatusCheckClean);
689    }
690
691    // The sections, assembled.
692    let configuration = resolution.params.as_ref().map(|params| Configuration {
693        tech: params.tech().to_owned(),
694        forge: params.forge().to_owned(),
695        repo: params.repo().to_owned(),
696        workflow: params.workflow().as_str().to_owned(),
697        style: params.style().map(|style| style.as_str().to_owned()),
698        nix: params.nix(),
699        trunk: params.trunk().to_owned(),
700        line_prefix: params.line_prefix().to_owned(),
701        security_contact: params.security_contact().to_owned(),
702        security_response: params.security_response().to_owned(),
703        sources: resolution.sources.clone(),
704        evidence_refs: resolution_refs.clone(),
705    });
706    let record_view = match &observation.record {
707        RecordRead::Absent => RecordState::Absent,
708        RecordRead::Present { manifest, bytes } => RecordState::Present {
709            rk_version: manifest.rk_version.clone(),
710            payload_sha256: manifest.payload_sha256.clone(),
711            schema_version: manifest.schema_version,
712            origin: manifest.origin.clone(),
713            sha256: Digest::of(bytes),
714        },
715        RecordRead::Invalid { reason } => RecordState::Invalid {
716            reason: reason.clone(),
717        },
718    };
719    let configuration_view = match &observation.config {
720        ConfigRead::Absent => ConfigurationState {
721            present: false,
722            sha256: None,
723            invalid: None,
724            pending: Vec::new(),
725        },
726        ConfigRead::Present { config, bytes } => ConfigurationState {
727            present: true,
728            sha256: Some(Digest::of(bytes)),
729            invalid: None,
730            pending: recorded
731                .map_or_else(Vec::new, |record| crate::config::pending(config, record)),
732        },
733        ConfigRead::Invalid { reason, bytes } => ConfigurationState {
734            present: true,
735            sha256: Some(Digest::of(bytes)),
736            invalid: Some(reason.clone()),
737            pending: Vec::new(),
738        },
739    };
740    let mut destination_paths: Vec<String> = entries
741        .iter()
742        .map(|entry| entry.destination.clone())
743        .chain(
744            recorded
745                .into_iter()
746                .flat_map(|record| record.files.iter().map(|file| file.destination.clone())),
747        )
748        .collect();
749    destination_paths.sort();
750    destination_paths.dedup();
751    let destinations: Vec<Destination> = destination_paths
752        .into_iter()
753        .map(|path| {
754            let bytes = observation.files.get(&path);
755            Destination {
756                present: bytes.is_some(),
757                sha256: bytes.map(|bytes| Digest::of(bytes)),
758                recorded_kind: recorded
759                    .and_then(|record| record.file(&path))
760                    .map(|file| file.kind),
761                path,
762            }
763        })
764        .collect();
765    let installation_refs: Vec<String> = record_ref
766        .iter()
767        .chain(config_ref.iter())
768        .cloned()
769        .chain(observation.refs.destinations.values().cloned())
770        .collect();
771    let forge_view = match &observation.forge {
772        ForgeRead::NotObserved { reason } => ForgeState::NotObserved {
773            reason: reason.clone(),
774        },
775        ForgeRead::Observed { trunk, remote_tip } => ForgeState::Observed {
776            trunk: trunk.clone(),
777            remote_tip: remote_tip.clone(),
778            evidence_refs: observation.refs.forge.clone(),
779        },
780    };
781    let mut plan = Plan {
782        schema: PLAN_SCHEMA,
783        identity: Identity {
784            plan_id: String::new(),
785            created_at: clock.to_owned(),
786            engine_version: engine_version.to_owned(),
787        },
788        classification,
789        findings,
790        desired_state: DesiredState {
791            selector: selector.to_owned(),
792            release: ResolvedRelease {
793                version: candidate_version.clone(),
794                venue: candidate.venue.to_owned(),
795                payload_sha256: candidate.manifest.payload_sha256.clone(),
796                payload_schema: bundle_schema,
797            },
798            configuration,
799            unresolved: resolution.unresolved.clone(),
800        },
801        observed_state: ObservedState {
802            repository: Repository {
803                target: observation.target.clone(),
804                git: observation.git,
805                tags: observation.facts.tags,
806                long_lived_branches: observation.facts.long_lived_branches.clone(),
807                release_markers: observation.facts.release_markers.clone(),
808                collisions: observation.facts.collisions.clone(),
809                tech: observation.tech.clone(),
810                forge: observation.forge_name.clone(),
811                repo: observation.repo.clone(),
812                verdict,
813                evidence_refs: observation.refs.repository.clone(),
814            },
815            installation: Installation {
816                record: record_view,
817                configuration: configuration_view,
818                destinations,
819                evidence_refs: installation_refs,
820            },
821            host: Host {
822                engine_version: engine_version.to_owned(),
823                pin: observation.pin.clone(),
824                evidence_refs: observation
825                    .refs
826                    .host
827                    .iter()
828                    .chain(observation.refs.pin.iter())
829                    .cloned()
830                    .collect(),
831            },
832            forge: forge_view,
833        },
834        release: Release {
835            candidate: BundleIdentity {
836                version: candidate_version,
837                payload_sha256: candidate.manifest.payload_sha256.clone(),
838                payload_schema: bundle_schema,
839                artifacts: candidate.manifest.artifacts.len(),
840                evidence_refs: vec![candidate_ref],
841            },
842            verification: candidate.verification,
843            baseline: baseline_state,
844            compatibility: Compatibility {
845                engine_schema: PAYLOAD_SCHEMA,
846                bundle_schema,
847                readable: bundle_schema <= PAYLOAD_SCHEMA,
848            },
849            guidance: Guidance {
850                coverage: "not-shipped",
851            },
852        },
853        operations,
854        preconditions,
855        decisions,
856        postconditions,
857        evidence: observation.ledger.into_items(),
858        readiness,
859        input_fingerprint: Digest::of(b""),
860    };
861    plan.input_fingerprint = fingerprint::compute(&plan);
862    plan.identity.plan_id = fingerprint::plan_id(&plan.input_fingerprint, clock);
863    Ok(Planned { plan, blobs })
864}
865
866/// The comparison's outcome for one destination.
867struct Outcome {
868    write: bool,
869    conflict: bool,
870    missing: bool,
871    record: FileRecord,
872}
873
874/// A destination on a target with no record: it lands as `rk init`
875/// lands it. A differing `rendered` destination is a conflict, a
876/// differing `seeded` or `state` one is the target's and is kept.
877fn compare_fresh(entry: &Entry, disk: Option<&[u8]>) -> Outcome {
878    let (write, conflict, landed) = match disk {
879        None => (true, false, entry.rendered.clone()),
880        Some(bytes) if bytes == entry.rendered => (false, false, bytes.to_vec()),
881        Some(bytes) if entry.kind != Kind::Rendered => (false, false, bytes.to_vec()),
882        Some(_) => (false, true, entry.rendered.clone()),
883    };
884    Outcome {
885        write,
886        conflict,
887        missing: false,
888        record: FileRecord {
889            destination: entry.destination.clone(),
890            kind: entry.kind,
891            sha256: Digest::of(&landed),
892            baseline_sha256: baseline_digest(entry),
893        },
894    }
895}
896
897/// A destination on a recorded target: the three digests decide it, in
898/// the shape `rk upgrade` has always decided by.
899fn compare_recorded(entry: &Entry, recorded: Option<&FileRecord>, disk: Option<&[u8]>) -> Outcome {
900    let Some(recorded) = recorded else {
901        return compare_fresh(entry, disk);
902    };
903    let candidate_record = |sha256: Digest| FileRecord {
904        destination: entry.destination.clone(),
905        kind: entry.kind,
906        sha256,
907        baseline_sha256: baseline_digest(entry),
908    };
909    // A seeded file this payload reclassifies as rendered claims ownership
910    // of a file the target may have tuned; only untouched bytes permit it.
911    if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
912        let untouched =
913            disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
914        return Outcome {
915            write: untouched,
916            conflict: !untouched,
917            missing: disk.is_none(),
918            record: candidate_record(Digest::of(&entry.rendered)),
919        };
920    }
921    match entry.kind {
922        Kind::Rendered => match disk {
923            Some(bytes) if Digest::of(bytes) == recorded.sha256 => Outcome {
924                write: bytes != entry.rendered,
925                conflict: false,
926                missing: false,
927                record: candidate_record(Digest::of(&entry.rendered)),
928            },
929            Some(bytes) if bytes == entry.rendered => Outcome {
930                write: false,
931                conflict: false,
932                missing: false,
933                record: candidate_record(Digest::of(&entry.rendered)),
934            },
935            other => Outcome {
936                write: false,
937                conflict: true,
938                missing: other.is_none(),
939                record: candidate_record(Digest::of(&entry.rendered)),
940            },
941        },
942        Kind::Seeded => {
943            // Never written; the record follows the target's bytes and
944            // keeps the baseline it tunes away from.
945            let baseline = if recorded.kind == Kind::Rendered {
946                Some(recorded.sha256.clone())
947            } else {
948                recorded.baseline_sha256.clone()
949            };
950            let sha256 = disk.map_or_else(|| recorded.sha256.clone(), Digest::of);
951            Outcome {
952                write: false,
953                conflict: false,
954                missing: false,
955                record: FileRecord {
956                    destination: entry.destination.clone(),
957                    kind: entry.kind,
958                    sha256,
959                    baseline_sha256: baseline,
960                },
961            }
962        }
963        Kind::State => Outcome {
964            write: false,
965            conflict: false,
966            missing: false,
967            record: FileRecord {
968                destination: entry.destination.clone(),
969                kind: entry.kind,
970                sha256: recorded.sha256.clone(),
971                baseline_sha256: None,
972            },
973        },
974    }
975}
976
977/// The digest a fresh record keeps as a destination's baseline.
978fn baseline_digest(entry: &Entry) -> Option<Digest> {
979    match entry.kind {
980        Kind::State => None,
981        Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
982    }
983}
984
985/// The recorded release's bytes for one destination, where the baseline
986/// bundle carries the artifact the record's baseline digest names.
987fn baseline_bytes(source: &dyn ReleaseSource, record: &Manifest, entry: &Entry) -> Option<Vec<u8>> {
988    let digest = record.file(&entry.destination)?.baseline_sha256.as_ref()?;
989    source.blob(digest).ok()
990}
991
992/// The record an apply writes: the candidate's identity, the resolved
993/// parameters, every compared destination, and the candidate's pins,
994/// with the first landing's instant and origin preserved where a record
995/// exists.
996fn planned_manifest<'a>(
997    candidate: &Candidate<'_>,
998    params: &landing::Params,
999    recorded: Option<&Manifest>,
1000    clock: &str,
1001    files: impl Iterator<Item = &'a FileRecord>,
1002) -> Manifest {
1003    let pins = crate::release::read(candidate.source, &candidate.manifest, "versions.toml")
1004        .map(|bytes| crate::registry::pins_in(&String::from_utf8_lossy(&bytes)))
1005        .unwrap_or_default()
1006        .into_iter()
1007        .filter(|pin| pin.used_by.iter().any(|user| user == params.tech()))
1008        .map(|pin| (pin.name, pin.version))
1009        .collect();
1010    Manifest {
1011        schema_version: manifest::SCHEMA_VERSION,
1012        rk_version: candidate.manifest.release_kit_version.clone(),
1013        payload_sha256: candidate.manifest.payload_sha256.clone(),
1014        origin: recorded.map_or_else(|| "init".to_owned(), |record| record.origin.clone()),
1015        tech: params.tech().to_owned(),
1016        forge: params.forge().to_owned(),
1017        landed_at: recorded.map_or_else(|| clock.to_owned(), |record| record.landed_at.clone()),
1018        parameters: Parameters {
1019            repo: params.repo().to_owned(),
1020            workflow: params.workflow(),
1021            style: params.style(),
1022            nix: params.nix(),
1023            trunk: params.trunk().to_owned(),
1024            line_prefix: params.line_prefix().to_owned(),
1025            security_contact: params.security_contact().to_owned(),
1026            security_response: params.security_response().to_owned(),
1027        },
1028        files: files
1029            .map(|file| FileRecord {
1030                destination: file.destination.clone(),
1031                kind: file.kind,
1032                sha256: file.sha256.clone(),
1033                baseline_sha256: file.baseline_sha256.clone(),
1034            })
1035            .collect(),
1036        pins,
1037    }
1038}
1039
1040/// A version with its leading `v` removed, so a manager's recorded form
1041/// compares against the crate's.
1042fn trim_v(version: &str) -> &str {
1043    version.strip_prefix('v').unwrap_or(version)
1044}