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