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                && let Some(baseline_bytes) = baseline_bytes(source, record, entry)
252            {
253                blobs.insert(Digest::of(&baseline_bytes), baseline_bytes);
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 pin_behind: Option<String> = None;
376    if let Some(pin) = &observation.pin
377        && trim_v(&pin.version) != trim_v(&candidate_version)
378    {
379        // A one-fact manager moves by one rewrite the apply can stage.
380        // The flake pair needs nix and the network, which an offline
381        // apply never has, so that move stays the sync verb's.
382        if pin.manager == "flake" {
383            pin_behind = Some(format!(
384                "the flake pin records {} and the candidate is {candidate_version}; the self-depend sync verb moves it under --apply, with nix and the network",
385                pin.version
386            ));
387        } else if intent == Intent::Adopt {
388            // An adoption writes the record and nothing else, so a
389            // stale pin is reported and never moved.
390            pin_behind = Some(format!(
391                "the {} pin records {} and the candidate is {candidate_version}; an adoption writes the record alone, so rk self-depend sync moves it",
392                pin.manager, pin.version
393            ));
394        } else if landing_planned {
395            let recorded_form = crate::self_depend::manager::Manager::ALL
396                .into_iter()
397                .find(|m| m.as_str() == pin.manager)
398                .map_or_else(
399                    || candidate_version.clone(),
400                    |m| m.recorded(&candidate_version),
401                );
402            operations.push(Operation::UpdatePin {
403                manager: pin.manager.clone(),
404                before: pin.version.clone(),
405                after: recorded_form,
406            });
407        }
408    }
409    let planned_record = match (&resolution.params, record_state) {
410        (Some(params), ClassifyRecord::Absent | ClassifyRecord::Present) if landing_planned => {
411            let record = planned_manifest(
412                &candidate,
413                params,
414                recorded,
415                intent,
416                clock,
417                compared.iter().map(|c| &c.record),
418            );
419            let bytes = manifest::render(&record)?;
420            let after = Digest::of(&bytes);
421            let before = match &observation.record {
422                RecordRead::Present { bytes, .. } => Some(Digest::of(bytes)),
423                RecordRead::Absent | RecordRead::Invalid { .. } => None,
424            };
425            if before.as_ref() == Some(&after) {
426                None
427            } else {
428                blobs.insert(after.clone(), bytes);
429                if let RecordRead::Present { bytes, .. } = &observation.record {
430                    blobs.insert(Digest::of(bytes), bytes.clone());
431                }
432                Some(Operation::WriteRecord { before, after })
433            }
434        }
435        _ => None,
436    };
437    if let Some(operation) = planned_record {
438        operations.push(operation);
439    }
440
441    // The preconditions, each with its requirement.
442    let record_ref = observation.refs.record.clone();
443    let config_ref = observation.refs.configuration.clone();
444    let resolution_refs: Vec<String> = record_ref
445        .iter()
446        .chain(config_ref.iter())
447        .chain(observation.refs.repository.iter())
448        .cloned()
449        .collect();
450    preconditions.push(Precondition {
451        id: "technology-resolved".into(),
452        requirement: Requirement::Required,
453        evaluation: resolution
454            .unresolved
455            .as_ref()
456            .map_or(Evaluation::Satisfied, |reason| Evaluation::Unsatisfied {
457                reason: reason.clone(),
458            }),
459        decision: None,
460        evidence_refs: resolution_refs.clone(),
461    });
462    // The preview placeholder renders, and never lands: a rendered file
463    // carrying `OWNER` names nobody's project, so a plan that would write
464    // one is blocked until the repository is answered.
465    preconditions.push(Precondition {
466        id: "repository-resolved".into(),
467        requirement: Requirement::Required,
468        evaluation: if resolution.repo_placeholder {
469            Evaluation::Unsatisfied {
470                reason: format!(
471                    "no origin remote, no committed repo, and no --repo answered the project path, so the preview stands in {}",
472                    landing::REPO_PLACEHOLDER
473                ),
474            }
475        } else {
476            Evaluation::Satisfied
477        },
478        decision: None,
479        evidence_refs: resolution_refs.clone(),
480    });
481    match (intent, record_state) {
482        (Intent::Setup | Intent::Adopt, ClassifyRecord::Present) => {
483            preconditions.push(Precondition {
484                id: "record-absent".into(),
485                requirement: Requirement::Required,
486                evaluation: Evaluation::Unsatisfied {
487                    reason: format!(
488                        "the target already carries {}; rk upgrade takes it to a newer payload",
489                        manifest::MANIFEST_PATH
490                    ),
491                },
492                decision: None,
493                evidence_refs: record_ref.iter().cloned().collect(),
494            });
495        }
496        (Intent::Upgrade, ClassifyRecord::Absent) => {
497            preconditions.push(Precondition {
498                id: "record-present".into(),
499                requirement: Requirement::Required,
500                evaluation: Evaluation::Unsatisfied {
501                    reason: format!(
502                        "no {} at the target: there is no baseline to upgrade against",
503                        manifest::MANIFEST_PATH
504                    ),
505                },
506                decision: None,
507                evidence_refs: record_ref.iter().cloned().collect(),
508            });
509        }
510        _ => {}
511    }
512    for c in &adopt_missing {
513        let path = &c.entry.destination;
514        preconditions.push(Precondition {
515            id: format!("destination-present:{path}"),
516            requirement: Requirement::Required,
517            evaluation: Evaluation::Unsatisfied {
518                reason: "expected and missing".to_owned(),
519            },
520            decision: None,
521            evidence_refs: observation
522                .refs
523                .destinations
524                .get(path)
525                .cloned()
526                .into_iter()
527                .collect(),
528        });
529    }
530    if let RecordRead::Invalid { reason } = &observation.record {
531        preconditions.push(Precondition {
532            id: "record-readable".into(),
533            requirement: Requirement::Required,
534            evaluation: Evaluation::Unsatisfied {
535                reason: reason.clone(),
536            },
537            decision: None,
538            evidence_refs: record_ref.iter().cloned().collect(),
539        });
540    } else if recorded.is_some() {
541        preconditions.push(Precondition {
542            id: "record-readable".into(),
543            requirement: Requirement::Required,
544            evaluation: Evaluation::Satisfied,
545            decision: None,
546            evidence_refs: record_ref.iter().cloned().collect(),
547        });
548    }
549    if let ConfigRead::Invalid { reason, .. } = &observation.config {
550        preconditions.push(Precondition {
551            id: "configuration-readable".into(),
552            requirement: Requirement::Required,
553            evaluation: Evaluation::Unsatisfied {
554                reason: reason.clone(),
555            },
556            decision: None,
557            evidence_refs: config_ref.iter().cloned().collect(),
558        });
559    }
560    if let Some(record) = recorded {
561        let newer =
562            manifest::alignment(&record.rk_version, engine_version) == Alignment::TargetNewer;
563        if newer {
564            findings.push(Finding {
565                code: "record-newer".into(),
566                detail: record.rk_version.clone(),
567            });
568        }
569        preconditions.push(Precondition {
570            id: "engine-not-older-than-record".into(),
571            requirement: Requirement::Required,
572            evaluation: if newer {
573                Evaluation::Unsatisfied {
574                    reason: format!(
575                        "the record came from rk {}, newer than this engine's {engine_version}; install release-kit {} or newer",
576                        record.rk_version, record.rk_version
577                    ),
578                }
579            } else {
580                Evaluation::Satisfied
581            },
582            decision: None,
583            evidence_refs: record_ref.iter().cloned().collect(),
584        });
585    }
586    let bundle_schema = candidate.manifest.payload_schema;
587    preconditions.push(Precondition {
588        id: "bundle-schema-readable".into(),
589        requirement: Requirement::Required,
590        evaluation: if bundle_schema <= PAYLOAD_SCHEMA {
591            Evaluation::Satisfied
592        } else {
593            Evaluation::Unsatisfied {
594                reason: format!(
595                    "the bundle declares payload schema {bundle_schema}, and this engine reads schema {PAYLOAD_SCHEMA} at most; install release-kit {candidate_version} or newer"
596                ),
597            }
598        },
599        decision: None,
600        evidence_refs: vec![candidate_ref.clone()],
601    });
602    if let Some(hooks_ref) = observation
603        .refs
604        .destinations
605        .get(landing::HOOKS_DESTINATION)
606        .cloned()
607    {
608        preconditions.push(Precondition {
609            id: "hooks-file-spliceable".into(),
610            requirement: Requirement::Required,
611            evaluation: observation
612                .hooks_defect
613                .as_ref()
614                .map_or(Evaluation::Satisfied, |defect| Evaluation::Unsatisfied {
615                    reason: defect.clone(),
616                }),
617            decision: None,
618            evidence_refs: vec![hooks_ref],
619        });
620    }
621    for c in compared.iter().filter(|c| c.conflict) {
622        let path = &c.entry.destination;
623        let mut refs: Vec<String> = observation
624            .refs
625            .destinations
626            .get(path)
627            .cloned()
628            .into_iter()
629            .collect();
630        refs.extend(record_ref.iter().cloned());
631        refs.extend(baseline_ref.iter().cloned());
632        preconditions.push(Precondition {
633            id: format!("owned-file-unedited:{path}"),
634            requirement: Requirement::Required,
635            evaluation: Evaluation::Unsatisfied {
636                reason: if c.missing {
637                    "the record names it and the disk does not hold it".to_owned()
638                } else if recorded.is_some() {
639                    "the target edited a file release-kit owns".to_owned()
640                } else {
641                    "the destination exists with different content".to_owned()
642                },
643            },
644            decision: None,
645            evidence_refs: refs,
646        });
647    }
648    let file_operations = operations
649        .iter()
650        .any(|operation| operation.path().is_some());
651    if recorded.is_some() {
652        let (requirement, evaluation, decision) = match &baseline_state {
653            BaselineState::NotObserved { reason } => {
654                let answered = selected.get("partial-baseline").map(String::as_str);
655                decisions.push(Decision {
656                    id: "partial-baseline".into(),
657                    question: "plan against the record's digests alone, with the recorded release's bytes unread?".into(),
658                    choices: vec![
659                        Choice {
660                            answer: "accept".into(),
661                            consequence: "the three-way comparison shows what diverged and cannot show the baseline it diverged from".into(),
662                        },
663                        Choice {
664                            answer: "fetch".into(),
665                            consequence: "re-plan with --fetch so the recorded release's bundle is read through the crates venue".into(),
666                        },
667                    ],
668                    selected: answered.map(str::to_owned),
669                });
670                (
671                    if file_operations {
672                        Requirement::DecisionRequired
673                    } else {
674                        Requirement::Advisory
675                    },
676                    if answered == Some("accept") {
677                        Evaluation::Satisfied
678                    } else {
679                        Evaluation::NotObserved {
680                            reason: reason.clone(),
681                        }
682                    },
683                    Some("partial-baseline".to_owned()),
684                )
685            }
686            _ => (Requirement::Advisory, Evaluation::Satisfied, None),
687        };
688        preconditions.push(Precondition {
689            id: "baseline-observed".into(),
690            requirement,
691            evaluation,
692            decision,
693            evidence_refs: baseline_ref.iter().cloned().collect(),
694        });
695    }
696    if recorded.is_none() && record_state == ClassifyRecord::Absent {
697        let source = resolution
698            .sources
699            .get("workflow")
700            .map_or("default", String::as_str);
701        let answered = (source != "default").then(|| {
702            resolution.params.as_ref().map_or_else(
703                || "worktree".to_owned(),
704                |p| p.workflow().as_str().to_owned(),
705            )
706        });
707        decisions.push(Decision {
708            id: "workflow-mode".into(),
709            question: "which working-copy mode does this project choose?".into(),
710            choices: vec![
711                Choice {
712                    answer: "worktree".into(),
713                    consequence: "every code-changing branch lives in a linked worktree and the main checkout commits nothing".into(),
714                },
715                Choice {
716                    answer: "branches".into(),
717                    consequence: "branches are worked in the main checkout, with worktrees optional beside it".into(),
718                },
719            ],
720            selected: answered.clone(),
721        });
722        preconditions.push(Precondition {
723            id: "workflow-mode-answered".into(),
724            requirement: Requirement::DecisionRequired,
725            evaluation: if answered.is_some() {
726                Evaluation::Satisfied
727            } else {
728                Evaluation::NotObserved {
729                    reason: "no flag, configuration, or decision names the mode".into(),
730                }
731            },
732            decision: Some("workflow-mode".into()),
733            evidence_refs: resolution_refs.clone(),
734        });
735    }
736    if let Some(record) = recorded
737        && record.parameters.style.is_none()
738    {
739        let source = resolution
740            .sources
741            .get("style")
742            .map_or("default", String::as_str);
743        let answered = (source != "default").then(|| {
744            resolution
745                .params
746                .as_ref()
747                .and_then(landing::Params::style)
748                .map_or_else(|| "trunk".to_owned(), |s| s.as_str().to_owned())
749        });
750        decisions.push(Decision {
751            id: "release-style".into(),
752            question: "the record predates the release style; which one does this project run?"
753                .into(),
754            choices: vec![
755                Choice {
756                    answer: "trunk".into(),
757                    consequence: "the bot's release request is armed to merge itself".into(),
758                },
759                Choice {
760                    answer: "lines".into(),
761                    consequence:
762                        "every merge is a human's, and release lines carry the maintained versions"
763                            .into(),
764                },
765            ],
766            selected: answered.clone(),
767        });
768        preconditions.push(Precondition {
769            id: "release-style-answered".into(),
770            requirement: Requirement::DecisionRequired,
771            evaluation: if answered.is_some() {
772                Evaluation::Satisfied
773            } else {
774                Evaluation::NotObserved {
775                    reason: "neither the configuration nor a decision names the style".into(),
776                }
777            },
778            decision: Some("release-style".into()),
779            evidence_refs: resolution_refs.clone(),
780        });
781    }
782    if recorded.is_none() && verdict == Verdict::NeedsDecision {
783        let answered = selected.get("release-activity").cloned();
784        decisions.push(Decision {
785            id: "release-activity".into(),
786            question: "tags or a second long-lived branch exist with no mechanism behind them; what are they?".into(),
787            choices: vec![
788                Choice {
789                    answer: "history".into(),
790                    consequence: "the activity is history the landing leaves in place, and the plan proceeds as a setup".into(),
791                },
792                Choice {
793                    answer: "migrate".into(),
794                    consequence: "another mechanism made them; follow the migration procedure and retire it first".into(),
795                },
796            ],
797            selected: answered.clone(),
798        });
799        preconditions.push(Precondition {
800            id: "release-activity-explained".into(),
801            requirement: Requirement::DecisionRequired,
802            // Only an answer the decision declares satisfies it, so an
803            // answer outside the closed set leaves the plan waiting
804            // rather than reading as decided.
805            evaluation: if super::decision_answered("release-activity", answered.as_deref()) {
806                Evaluation::Satisfied
807            } else {
808                Evaluation::NotObserved {
809                    reason: "the operator has not said what the release activity is".into(),
810                }
811            },
812            decision: Some("release-activity".into()),
813            evidence_refs: observation.refs.repository.clone(),
814        });
815    }
816    preconditions.push(Precondition {
817        id: "forge-observed".into(),
818        requirement: Requirement::Advisory,
819        evaluation: match &observation.forge {
820            ForgeRead::Observed { .. } => Evaluation::Satisfied,
821            ForgeRead::NotObserved { reason } => Evaluation::NotObserved {
822                reason: reason.clone(),
823            },
824        },
825        decision: None,
826        evidence_refs: observation.refs.forge.clone(),
827    });
828    if let Some(reason) = pin_behind {
829        preconditions.push(Precondition {
830            id: "pin-current".into(),
831            requirement: Requirement::Advisory,
832            evaluation: Evaluation::Unsatisfied { reason },
833            decision: None,
834            evidence_refs: observation.refs.pin.iter().cloned().collect(),
835        });
836    }
837    preconditions.push(Precondition {
838        id: "pin-wired".into(),
839        requirement: Requirement::Advisory,
840        evaluation: if observation.pin.is_some() {
841            Evaluation::Satisfied
842        } else {
843            Evaluation::NotObserved {
844                reason: "no manager file names release-kit".into(),
845            }
846        },
847        decision: None,
848        evidence_refs: observation.refs.pin.iter().cloned().collect(),
849    });
850
851    // The compatibility axes beyond the schema, and the guidance the
852    // bundle carries for this target, each into its preconditions.
853    let writes: Vec<String> = operations
854        .iter()
855        .filter_map(|operation| operation.path().map(str::to_owned))
856        .collect();
857    let rewrites: Vec<String> = operations
858        .iter()
859        .filter_map(|operation| match operation {
860            Operation::WriteFile {
861                path,
862                before: Some(_),
863                ..
864            }
865            | Operation::SpliceBlock {
866                path,
867                before: Some(_),
868                ..
869            } => Some(path.clone()),
870            _ => None,
871        })
872        .collect();
873    let pins: BTreeMap<String, String> =
874        crate::release::read(candidate.source, &candidate.manifest, "versions.toml")
875            .map(|bytes| crate::registry::pins_in(&String::from_utf8_lossy(&bytes)))
876            .unwrap_or_default()
877            .into_iter()
878            .map(|pin| (pin.name, pin.version))
879            .collect();
880    let recorded_version = recorded.map(|record| record.rk_version.as_str());
881    let forge_version = match &observation.forge {
882        ForgeRead::Observed { version, .. } => version.as_deref(),
883        ForgeRead::NotObserved { .. } => None,
884    };
885    let mut axis_refs: Vec<String> = vec![candidate_ref.clone()];
886    axis_refs.extend(record_ref.iter().cloned());
887    axis_refs.extend(observation.refs.host.iter().cloned());
888    axis_refs.extend(observation.refs.forge.iter().cloned());
889    axis_refs.extend(observation.refs.pin.iter().cloned());
890    let evaluated = compatibility::evaluate(&compatibility::Inputs {
891        declared,
892        engine_version,
893        engine_schema: PAYLOAD_SCHEMA,
894        bundle_schema,
895        candidate_version: &candidate_version,
896        recorded_version,
897        tech: resolution.params.as_ref().map(landing::Params::tech),
898        forge: resolution.params.as_ref().map(landing::Params::forge),
899        pins: &pins,
900        host_generator: observation
901            .generator
902            .as_ref()
903            .and_then(|generator| generator.host.as_deref()),
904        writes: &writes,
905        rewrites: &rewrites,
906        pin_wired: observation.pin.is_some(),
907        unwired_managers: &observation.unwired_managers,
908        forge_version,
909        selected,
910        evidence_refs: axis_refs,
911    });
912    preconditions.extend(evaluated.preconditions);
913    decisions.extend(evaluated.decisions);
914    let present: std::collections::BTreeSet<String> = observation.files.keys().cloned().collect();
915    let rendered_write = compared
916        .iter()
917        .any(|c| c.write && !c.conflict && c.entry.kind == Kind::Rendered);
918    let mut guidance_refs: Vec<String> = vec![candidate_ref.clone()];
919    guidance_refs.extend(record_ref.iter().cloned());
920    let selected_guidance = guidance::select(&guidance::Inputs {
921        recorded_version,
922        candidate_version: &candidate_version,
923        carries_root: carries_guidance,
924        since: declared.guidance.since.as_deref(),
925        files: guidance_files,
926        present: &present,
927        rendered_write,
928        selected,
929        evidence_refs: guidance_refs,
930    });
931    preconditions.extend(selected_guidance.precondition);
932    decisions.extend(selected_guidance.decision);
933    let readiness = readiness::derive(&preconditions);
934
935    // The postconditions, one per operation kind that leaves a check.
936    let mut postconditions: Vec<Postcondition> = Vec::new();
937    for operation in &operations {
938        match operation {
939            Operation::WriteFile { path, after, .. }
940            | Operation::SpliceBlock { path, after, .. } => {
941                postconditions.push(Postcondition::DestinationHolds {
942                    path: path.clone(),
943                    sha256: after.clone(),
944                });
945            }
946            Operation::WriteRecord { after, .. } => {
947                postconditions.push(Postcondition::RecordReadsBack {
948                    sha256: after.clone(),
949                });
950            }
951            Operation::UpdatePin { manager, after, .. } => {
952                postconditions.push(Postcondition::PinReads {
953                    manager: manager.clone(),
954                    version: after.clone(),
955                });
956            }
957            Operation::RemoveOwnedFile { .. } => {}
958        }
959    }
960    // The standing verifier runs last and its answer is reported: it
961    // judges the whole target, sentinels the operator still owes included,
962    // so it names what is short rather than failing the apply.
963    if !operations.is_empty() {
964        postconditions.push(Postcondition::StatusCheckClean);
965    }
966
967    // The sections, assembled.
968    let configuration = resolution.params.as_ref().map(|params| Configuration {
969        tech: params.tech().to_owned(),
970        forge: params.forge().to_owned(),
971        repo: params.repo().to_owned(),
972        workflow: params.workflow().as_str().to_owned(),
973        style: params.style().map(|style| style.as_str().to_owned()),
974        nix: params.nix(),
975        trunk: params.trunk().to_owned(),
976        line_prefix: params.line_prefix().to_owned(),
977        security_contact: params.security_contact().to_owned(),
978        security_response: params.security_response().to_owned(),
979        sources: resolution.sources.clone(),
980        evidence_refs: resolution_refs.clone(),
981    });
982    let record_view = match &observation.record {
983        RecordRead::Absent => RecordState::Absent,
984        RecordRead::Present { manifest, bytes } => RecordState::Present {
985            rk_version: manifest.rk_version.clone(),
986            payload_sha256: manifest.payload_sha256.clone(),
987            schema_version: manifest.schema_version,
988            origin: manifest.origin.clone(),
989            sha256: Digest::of(bytes),
990        },
991        RecordRead::Invalid { reason } => RecordState::Invalid {
992            reason: reason.clone(),
993        },
994    };
995    let configuration_view = match &observation.config {
996        ConfigRead::Absent => ConfigurationState {
997            present: false,
998            sha256: None,
999            invalid: None,
1000            pending: Vec::new(),
1001        },
1002        ConfigRead::Present { config, bytes } => ConfigurationState {
1003            present: true,
1004            sha256: Some(Digest::of(bytes)),
1005            invalid: None,
1006            pending: recorded
1007                .map_or_else(Vec::new, |record| crate::config::pending(config, record)),
1008        },
1009        ConfigRead::Invalid { reason, bytes } => ConfigurationState {
1010            present: true,
1011            sha256: Some(Digest::of(bytes)),
1012            invalid: Some(reason.clone()),
1013            pending: Vec::new(),
1014        },
1015    };
1016    let mut destination_paths: Vec<String> = entries
1017        .iter()
1018        .map(|entry| entry.destination.clone())
1019        .chain(
1020            recorded
1021                .into_iter()
1022                .flat_map(|record| record.files.iter().map(|file| file.destination.clone())),
1023        )
1024        .collect();
1025    destination_paths.sort();
1026    destination_paths.dedup();
1027    let destinations: Vec<Destination> = destination_paths
1028        .into_iter()
1029        .map(|path| {
1030            let bytes = observation.files.get(&path);
1031            Destination {
1032                present: bytes.is_some(),
1033                sha256: bytes.map(|bytes| Digest::of(bytes)),
1034                recorded_kind: recorded
1035                    .and_then(|record| record.file(&path))
1036                    .map(|file| file.kind),
1037                path,
1038            }
1039        })
1040        .collect();
1041    let installation_refs: Vec<String> = record_ref
1042        .iter()
1043        .chain(config_ref.iter())
1044        .cloned()
1045        .chain(observation.refs.destinations.values().cloned())
1046        .collect();
1047    let forge_view = match &observation.forge {
1048        ForgeRead::NotObserved { reason } => ForgeState::NotObserved {
1049            reason: reason.clone(),
1050        },
1051        ForgeRead::Observed {
1052            trunk, remote_tip, ..
1053        } => ForgeState::Observed {
1054            trunk: trunk.clone(),
1055            remote_tip: remote_tip.clone(),
1056            evidence_refs: observation.refs.forge.clone(),
1057        },
1058    };
1059    let mut plan = Plan {
1060        schema: PLAN_SCHEMA.into(),
1061        identity: Identity {
1062            plan_id: String::new(),
1063            created_at: clock.to_owned(),
1064            engine_version: engine_version.to_owned(),
1065        },
1066        classification,
1067        findings,
1068        desired_state: DesiredState {
1069            intent,
1070            selector: selector.to_owned(),
1071            release: ResolvedRelease {
1072                version: candidate_version.clone(),
1073                venue: candidate.venue.to_owned(),
1074                payload_sha256: candidate.manifest.payload_sha256.clone(),
1075                payload_schema: bundle_schema,
1076            },
1077            configuration,
1078            unresolved: resolution.unresolved.clone(),
1079        },
1080        observed_state: ObservedState {
1081            repository: Repository {
1082                target: observation.target.clone(),
1083                git: observation.git,
1084                tags: observation.facts.tags,
1085                long_lived_branches: observation.facts.long_lived_branches.clone(),
1086                release_markers: observation.facts.release_markers.clone(),
1087                collisions: observation.facts.collisions.clone(),
1088                tech: observation.tech.clone(),
1089                forge: observation.forge_name.clone(),
1090                repo: observation.repo.clone(),
1091                verdict,
1092                evidence_refs: observation.refs.repository.clone(),
1093            },
1094            installation: Installation {
1095                record: record_view,
1096                configuration: configuration_view,
1097                destinations,
1098                evidence_refs: installation_refs,
1099            },
1100            host: Host {
1101                engine_version: engine_version.to_owned(),
1102                pin: observation.pin.clone(),
1103                evidence_refs: observation
1104                    .refs
1105                    .host
1106                    .iter()
1107                    .chain(observation.refs.pin.iter())
1108                    .cloned()
1109                    .collect(),
1110            },
1111            forge: forge_view,
1112        },
1113        release: Release {
1114            candidate: BundleIdentity {
1115                version: candidate_version,
1116                payload_sha256: candidate.manifest.payload_sha256.clone(),
1117                payload_schema: bundle_schema,
1118                artifacts: candidate.manifest.artifacts.len(),
1119                evidence_refs: vec![candidate_ref],
1120            },
1121            verification: candidate.verification,
1122            baseline: baseline_state,
1123            compatibility: evaluated.facts,
1124            guidance: selected_guidance.guidance,
1125        },
1126        operations,
1127        preconditions,
1128        decisions,
1129        postconditions,
1130        evidence: observation.ledger.into_items(),
1131        readiness,
1132        input_fingerprint: Digest::of(b""),
1133    };
1134    plan.input_fingerprint = fingerprint::compute(&plan);
1135    plan.identity.plan_id = fingerprint::plan_id(&plan.input_fingerprint, clock);
1136    let withheld = resolution
1137        .nix_withheld
1138        .as_ref()
1139        .map(|(set, reason)| {
1140            set.iter()
1141                .map(|path| landing::Withheld {
1142                    path: path.clone(),
1143                    reason: reason.clone(),
1144                })
1145                .collect()
1146        })
1147        .unwrap_or_default();
1148    Ok(Planned {
1149        plan,
1150        blobs,
1151        outcomes,
1152        config,
1153        withheld,
1154    })
1155}
1156
1157/// The word the fronts print for one compared destination.
1158fn disposition(
1159    c: &Compared<'_>,
1160    recorded: Option<&Manifest>,
1161    disk: Option<&Vec<u8>>,
1162) -> Disposition {
1163    if c.conflict {
1164        return if c.missing {
1165            Disposition::Missing
1166        } else {
1167            Disposition::Conflict
1168        };
1169    }
1170    if c.write {
1171        return Disposition::Write;
1172    }
1173    let named = recorded.and_then(|record| record.file(&c.entry.destination));
1174    match (named, c.entry.kind) {
1175        (Some(_), Kind::Rendered) => Disposition::Unchanged,
1176        (Some(_), Kind::Seeded) => {
1177            let at_baseline = disk
1178                .map(|bytes| Digest::of(bytes))
1179                .is_some_and(|digest| Some(&digest) == c.record.baseline_sha256.as_ref());
1180            if at_baseline {
1181                Disposition::Unchanged
1182            } else {
1183                Disposition::Drift
1184            }
1185        }
1186        (Some(_), Kind::State) => Disposition::State,
1187        (None, _) => {
1188            if disk.is_some_and(|bytes| *bytes == c.entry.rendered) {
1189                Disposition::Unchanged
1190            } else {
1191                Disposition::Kept
1192            }
1193        }
1194    }
1195}
1196
1197/// The comparison's outcome for one destination.
1198struct Outcome {
1199    write: bool,
1200    conflict: bool,
1201    missing: bool,
1202    record: FileRecord,
1203}
1204
1205/// A destination on a target with no record: it lands as `rk init`
1206/// lands it. A differing `rendered` destination is a conflict, a
1207/// differing `seeded` or `state` one is the target's and is kept.
1208fn compare_fresh(entry: &Entry, disk: Option<&[u8]>) -> Outcome {
1209    let (write, conflict, landed) = match disk {
1210        None => (true, false, entry.rendered.clone()),
1211        Some(bytes) if bytes == entry.rendered => (false, false, bytes.to_vec()),
1212        Some(bytes) if entry.kind != Kind::Rendered => (false, false, bytes.to_vec()),
1213        Some(_) => (false, true, entry.rendered.clone()),
1214    };
1215    Outcome {
1216        write,
1217        conflict,
1218        missing: false,
1219        record: FileRecord {
1220            destination: entry.destination.clone(),
1221            kind: entry.kind,
1222            sha256: Digest::of(&landed),
1223            baseline_sha256: baseline_digest(entry),
1224        },
1225    }
1226}
1227
1228/// A destination on a recorded target: the three digests decide it, in
1229/// the shape `rk upgrade` has always decided by.
1230fn compare_recorded(entry: &Entry, recorded: Option<&FileRecord>, disk: Option<&[u8]>) -> Outcome {
1231    let Some(recorded) = recorded else {
1232        return compare_fresh(entry, disk);
1233    };
1234    let candidate_record = |sha256: Digest| FileRecord {
1235        destination: entry.destination.clone(),
1236        kind: entry.kind,
1237        sha256,
1238        baseline_sha256: baseline_digest(entry),
1239    };
1240    // A seeded file this payload reclassifies as rendered claims ownership
1241    // of a file the target may have tuned; only untouched bytes permit it.
1242    if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
1243        let untouched =
1244            disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
1245        return Outcome {
1246            write: untouched,
1247            conflict: !untouched,
1248            missing: disk.is_none(),
1249            record: candidate_record(Digest::of(&entry.rendered)),
1250        };
1251    }
1252    match entry.kind {
1253        Kind::Rendered => match disk {
1254            Some(bytes) if Digest::of(bytes) == recorded.sha256 => Outcome {
1255                write: bytes != entry.rendered,
1256                conflict: false,
1257                missing: false,
1258                record: candidate_record(Digest::of(&entry.rendered)),
1259            },
1260            Some(bytes) if bytes == entry.rendered => Outcome {
1261                write: false,
1262                conflict: false,
1263                missing: false,
1264                record: candidate_record(Digest::of(&entry.rendered)),
1265            },
1266            other => Outcome {
1267                write: false,
1268                conflict: true,
1269                missing: other.is_none(),
1270                record: candidate_record(Digest::of(&entry.rendered)),
1271            },
1272        },
1273        Kind::Seeded => {
1274            // Never written; the record follows the target's bytes and
1275            // keeps the baseline it tunes away from.
1276            let baseline = if recorded.kind == Kind::Rendered {
1277                Some(recorded.sha256.clone())
1278            } else {
1279                recorded.baseline_sha256.clone()
1280            };
1281            let sha256 = disk.map_or_else(|| recorded.sha256.clone(), Digest::of);
1282            Outcome {
1283                write: false,
1284                conflict: false,
1285                missing: false,
1286                record: FileRecord {
1287                    destination: entry.destination.clone(),
1288                    kind: entry.kind,
1289                    sha256,
1290                    baseline_sha256: baseline,
1291                },
1292            }
1293        }
1294        Kind::State => Outcome {
1295            write: false,
1296            conflict: false,
1297            missing: false,
1298            record: FileRecord {
1299                destination: entry.destination.clone(),
1300                kind: entry.kind,
1301                sha256: recorded.sha256.clone(),
1302                baseline_sha256: None,
1303            },
1304        },
1305    }
1306}
1307
1308/// The digest a fresh record keeps as a destination's baseline.
1309fn baseline_digest(entry: &Entry) -> Option<Digest> {
1310    match entry.kind {
1311        Kind::State => None,
1312        Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
1313    }
1314}
1315
1316/// The recorded release's bytes for one destination, where the baseline
1317/// bundle carries the artifact the record's baseline digest names.
1318fn baseline_bytes(source: &dyn ReleaseSource, record: &Manifest, entry: &Entry) -> Option<Vec<u8>> {
1319    let digest = record.file(&entry.destination)?.baseline_sha256.as_ref()?;
1320    source.blob(digest).ok()
1321}
1322
1323/// The record an apply writes: the candidate's identity, the resolved
1324/// parameters, every compared destination, and the candidate's pins,
1325/// with the first landing's instant and origin preserved where a record
1326/// exists.
1327fn planned_manifest<'a>(
1328    candidate: &Candidate<'_>,
1329    params: &landing::Params,
1330    recorded: Option<&Manifest>,
1331    intent: Intent,
1332    clock: &str,
1333    files: impl Iterator<Item = &'a FileRecord>,
1334) -> Manifest {
1335    let pins = crate::release::read(candidate.source, &candidate.manifest, "versions.toml")
1336        .map(|bytes| crate::registry::pins_in(&String::from_utf8_lossy(&bytes)))
1337        .unwrap_or_default()
1338        .into_iter()
1339        .filter(|pin| pin.used_by.iter().any(|user| user == params.tech()))
1340        .map(|pin| (pin.name, pin.version))
1341        .collect();
1342    Manifest {
1343        schema_version: manifest::SCHEMA_VERSION,
1344        rk_version: candidate.manifest.release_kit_version.clone(),
1345        payload_sha256: candidate.manifest.payload_sha256.clone(),
1346        origin: recorded.map_or_else(
1347            || {
1348                if intent == Intent::Adopt {
1349                    "adopt".to_owned()
1350                } else {
1351                    "init".to_owned()
1352                }
1353            },
1354            |record| record.origin.clone(),
1355        ),
1356        tech: params.tech().to_owned(),
1357        forge: params.forge().to_owned(),
1358        landed_at: recorded.map_or_else(|| clock.to_owned(), |record| record.landed_at.clone()),
1359        parameters: Parameters {
1360            repo: params.repo().to_owned(),
1361            workflow: params.workflow(),
1362            style: params.style(),
1363            nix: params.nix(),
1364            trunk: params.trunk().to_owned(),
1365            line_prefix: params.line_prefix().to_owned(),
1366            security_contact: params.security_contact().to_owned(),
1367            security_response: params.security_response().to_owned(),
1368        },
1369        files: files
1370            .map(|file| FileRecord {
1371                destination: file.destination.clone(),
1372                kind: file.kind,
1373                sha256: file.sha256.clone(),
1374                baseline_sha256: file.baseline_sha256.clone(),
1375            })
1376            .collect(),
1377        pins,
1378    }
1379}
1380
1381/// A version with its leading `v` removed, so a manager's recorded form
1382/// compares against the crate's.
1383fn trim_v(version: &str) -> &str {
1384    version.strip_prefix('v').unwrap_or(version)
1385}