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 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                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 intent == Intent::Adopt {
387                // An adoption writes the record and nothing else, so a
388                // stale pin is reported and never moved.
389                pin_behind = Some(format!(
390                    "the {} pin records {} and the candidate is {candidate_version}; an adoption writes the record alone, so rk self-depend sync moves it",
391                    pin.manager, pin.version
392                ));
393            } else if landing_planned {
394                let recorded_form = crate::self_depend::manager::Manager::ALL
395                    .into_iter()
396                    .find(|m| m.as_str() == pin.manager)
397                    .map_or_else(
398                        || candidate_version.clone(),
399                        |m| m.recorded(&candidate_version),
400                    );
401                operations.push(Operation::UpdatePin {
402                    manager: pin.manager.clone(),
403                    before: pin.version.clone(),
404                    after: recorded_form,
405                });
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        if record.parameters.style.is_none() {
738            let source = resolution
739                .sources
740                .get("style")
741                .map_or("default", String::as_str);
742            let answered = (source != "default").then(|| {
743                resolution
744                    .params
745                    .as_ref()
746                    .and_then(landing::Params::style)
747                    .map_or_else(|| "trunk".to_owned(), |s| s.as_str().to_owned())
748            });
749            decisions.push(Decision {
750                id: "release-style".into(),
751                question: "the record predates the release style; which one does this project run?".into(),
752                choices: vec![
753                    Choice {
754                        answer: "trunk".into(),
755                        consequence: "the bot's release request is armed to merge itself".into(),
756                    },
757                    Choice {
758                        answer: "lines".into(),
759                        consequence: "every merge is a human's, and release lines carry the maintained versions".into(),
760                    },
761                ],
762                selected: answered.clone(),
763            });
764            preconditions.push(Precondition {
765                id: "release-style-answered".into(),
766                requirement: Requirement::DecisionRequired,
767                evaluation: if answered.is_some() {
768                    Evaluation::Satisfied
769                } else {
770                    Evaluation::NotObserved {
771                        reason: "neither the configuration nor a decision names the style".into(),
772                    }
773                },
774                decision: Some("release-style".into()),
775                evidence_refs: resolution_refs.clone(),
776            });
777        }
778    }
779    if recorded.is_none() && verdict == Verdict::NeedsDecision {
780        let answered = selected.get("release-activity").cloned();
781        decisions.push(Decision {
782            id: "release-activity".into(),
783            question: "tags or a second long-lived branch exist with no mechanism behind them; what are they?".into(),
784            choices: vec![
785                Choice {
786                    answer: "history".into(),
787                    consequence: "the activity is history the landing leaves in place, and the plan proceeds as a setup".into(),
788                },
789                Choice {
790                    answer: "migrate".into(),
791                    consequence: "another mechanism made them; follow the migration procedure and retire it first".into(),
792                },
793            ],
794            selected: answered.clone(),
795        });
796        preconditions.push(Precondition {
797            id: "release-activity-explained".into(),
798            requirement: Requirement::DecisionRequired,
799            // Only an answer the decision declares satisfies it, so an
800            // answer outside the closed set leaves the plan waiting
801            // rather than reading as decided.
802            evaluation: if super::decision_answered("release-activity", answered.as_deref()) {
803                Evaluation::Satisfied
804            } else {
805                Evaluation::NotObserved {
806                    reason: "the operator has not said what the release activity is".into(),
807                }
808            },
809            decision: Some("release-activity".into()),
810            evidence_refs: observation.refs.repository.clone(),
811        });
812    }
813    preconditions.push(Precondition {
814        id: "forge-observed".into(),
815        requirement: Requirement::Advisory,
816        evaluation: match &observation.forge {
817            ForgeRead::Observed { .. } => Evaluation::Satisfied,
818            ForgeRead::NotObserved { reason } => Evaluation::NotObserved {
819                reason: reason.clone(),
820            },
821        },
822        decision: None,
823        evidence_refs: observation.refs.forge.clone(),
824    });
825    if let Some(reason) = pin_behind {
826        preconditions.push(Precondition {
827            id: "pin-current".into(),
828            requirement: Requirement::Advisory,
829            evaluation: Evaluation::Unsatisfied { reason },
830            decision: None,
831            evidence_refs: observation.refs.pin.iter().cloned().collect(),
832        });
833    }
834    preconditions.push(Precondition {
835        id: "pin-wired".into(),
836        requirement: Requirement::Advisory,
837        evaluation: if observation.pin.is_some() {
838            Evaluation::Satisfied
839        } else {
840            Evaluation::NotObserved {
841                reason: "no manager file names release-kit".into(),
842            }
843        },
844        decision: None,
845        evidence_refs: observation.refs.pin.iter().cloned().collect(),
846    });
847
848    // The compatibility axes beyond the schema, and the guidance the
849    // bundle carries for this target, each into its preconditions.
850    let writes: Vec<String> = operations
851        .iter()
852        .filter_map(|operation| operation.path().map(str::to_owned))
853        .collect();
854    let rewrites: Vec<String> = operations
855        .iter()
856        .filter_map(|operation| match operation {
857            Operation::WriteFile {
858                path,
859                before: Some(_),
860                ..
861            }
862            | Operation::SpliceBlock {
863                path,
864                before: Some(_),
865                ..
866            } => Some(path.clone()),
867            _ => None,
868        })
869        .collect();
870    let pins: BTreeMap<String, String> =
871        crate::release::read(candidate.source, &candidate.manifest, "versions.toml")
872            .map(|bytes| crate::registry::pins_in(&String::from_utf8_lossy(&bytes)))
873            .unwrap_or_default()
874            .into_iter()
875            .map(|pin| (pin.name, pin.version))
876            .collect();
877    let recorded_version = recorded.map(|record| record.rk_version.as_str());
878    let forge_version = match &observation.forge {
879        ForgeRead::Observed { version, .. } => version.as_deref(),
880        ForgeRead::NotObserved { .. } => None,
881    };
882    let mut axis_refs: Vec<String> = vec![candidate_ref.clone()];
883    axis_refs.extend(record_ref.iter().cloned());
884    axis_refs.extend(observation.refs.host.iter().cloned());
885    axis_refs.extend(observation.refs.forge.iter().cloned());
886    axis_refs.extend(observation.refs.pin.iter().cloned());
887    let evaluated = compatibility::evaluate(&compatibility::Inputs {
888        declared,
889        engine_version,
890        engine_schema: PAYLOAD_SCHEMA,
891        bundle_schema,
892        candidate_version: &candidate_version,
893        recorded_version,
894        tech: resolution.params.as_ref().map(landing::Params::tech),
895        forge: resolution.params.as_ref().map(landing::Params::forge),
896        pins: &pins,
897        host_generator: observation
898            .generator
899            .as_ref()
900            .and_then(|generator| generator.host.as_deref()),
901        writes: &writes,
902        rewrites: &rewrites,
903        pin_wired: observation.pin.is_some(),
904        unwired_managers: &observation.unwired_managers,
905        forge_version,
906        selected,
907        evidence_refs: axis_refs,
908    });
909    preconditions.extend(evaluated.preconditions);
910    decisions.extend(evaluated.decisions);
911    let present: std::collections::BTreeSet<String> = observation.files.keys().cloned().collect();
912    let rendered_write = compared
913        .iter()
914        .any(|c| c.write && !c.conflict && c.entry.kind == Kind::Rendered);
915    let mut guidance_refs: Vec<String> = vec![candidate_ref.clone()];
916    guidance_refs.extend(record_ref.iter().cloned());
917    let selected_guidance = guidance::select(&guidance::Inputs {
918        recorded_version,
919        candidate_version: &candidate_version,
920        carries_root: carries_guidance,
921        since: declared.guidance.since.as_deref(),
922        files: guidance_files,
923        present: &present,
924        rendered_write,
925        selected,
926        evidence_refs: guidance_refs,
927    });
928    preconditions.extend(selected_guidance.precondition);
929    decisions.extend(selected_guidance.decision);
930    let readiness = readiness::derive(&preconditions);
931
932    // The postconditions, one per operation kind that leaves a check.
933    let mut postconditions: Vec<Postcondition> = Vec::new();
934    for operation in &operations {
935        match operation {
936            Operation::WriteFile { path, after, .. }
937            | Operation::SpliceBlock { path, after, .. } => {
938                postconditions.push(Postcondition::DestinationHolds {
939                    path: path.clone(),
940                    sha256: after.clone(),
941                });
942            }
943            Operation::WriteRecord { after, .. } => {
944                postconditions.push(Postcondition::RecordReadsBack {
945                    sha256: after.clone(),
946                });
947            }
948            Operation::UpdatePin { manager, after, .. } => {
949                postconditions.push(Postcondition::PinReads {
950                    manager: manager.clone(),
951                    version: after.clone(),
952                });
953            }
954            Operation::RemoveOwnedFile { .. } => {}
955        }
956    }
957    // The standing verifier runs last and its answer is reported: it
958    // judges the whole target, sentinels the operator still owes included,
959    // so it names what is short rather than failing the apply.
960    if !operations.is_empty() {
961        postconditions.push(Postcondition::StatusCheckClean);
962    }
963
964    // The sections, assembled.
965    let configuration = resolution.params.as_ref().map(|params| Configuration {
966        tech: params.tech().to_owned(),
967        forge: params.forge().to_owned(),
968        repo: params.repo().to_owned(),
969        workflow: params.workflow().as_str().to_owned(),
970        style: params.style().map(|style| style.as_str().to_owned()),
971        nix: params.nix(),
972        trunk: params.trunk().to_owned(),
973        line_prefix: params.line_prefix().to_owned(),
974        security_contact: params.security_contact().to_owned(),
975        security_response: params.security_response().to_owned(),
976        sources: resolution.sources.clone(),
977        evidence_refs: resolution_refs.clone(),
978    });
979    let record_view = match &observation.record {
980        RecordRead::Absent => RecordState::Absent,
981        RecordRead::Present { manifest, bytes } => RecordState::Present {
982            rk_version: manifest.rk_version.clone(),
983            payload_sha256: manifest.payload_sha256.clone(),
984            schema_version: manifest.schema_version,
985            origin: manifest.origin.clone(),
986            sha256: Digest::of(bytes),
987        },
988        RecordRead::Invalid { reason } => RecordState::Invalid {
989            reason: reason.clone(),
990        },
991    };
992    let configuration_view = match &observation.config {
993        ConfigRead::Absent => ConfigurationState {
994            present: false,
995            sha256: None,
996            invalid: None,
997            pending: Vec::new(),
998        },
999        ConfigRead::Present { config, bytes } => ConfigurationState {
1000            present: true,
1001            sha256: Some(Digest::of(bytes)),
1002            invalid: None,
1003            pending: recorded
1004                .map_or_else(Vec::new, |record| crate::config::pending(config, record)),
1005        },
1006        ConfigRead::Invalid { reason, bytes } => ConfigurationState {
1007            present: true,
1008            sha256: Some(Digest::of(bytes)),
1009            invalid: Some(reason.clone()),
1010            pending: Vec::new(),
1011        },
1012    };
1013    let mut destination_paths: Vec<String> = entries
1014        .iter()
1015        .map(|entry| entry.destination.clone())
1016        .chain(
1017            recorded
1018                .into_iter()
1019                .flat_map(|record| record.files.iter().map(|file| file.destination.clone())),
1020        )
1021        .collect();
1022    destination_paths.sort();
1023    destination_paths.dedup();
1024    let destinations: Vec<Destination> = destination_paths
1025        .into_iter()
1026        .map(|path| {
1027            let bytes = observation.files.get(&path);
1028            Destination {
1029                present: bytes.is_some(),
1030                sha256: bytes.map(|bytes| Digest::of(bytes)),
1031                recorded_kind: recorded
1032                    .and_then(|record| record.file(&path))
1033                    .map(|file| file.kind),
1034                path,
1035            }
1036        })
1037        .collect();
1038    let installation_refs: Vec<String> = record_ref
1039        .iter()
1040        .chain(config_ref.iter())
1041        .cloned()
1042        .chain(observation.refs.destinations.values().cloned())
1043        .collect();
1044    let forge_view = match &observation.forge {
1045        ForgeRead::NotObserved { reason } => ForgeState::NotObserved {
1046            reason: reason.clone(),
1047        },
1048        ForgeRead::Observed {
1049            trunk, remote_tip, ..
1050        } => ForgeState::Observed {
1051            trunk: trunk.clone(),
1052            remote_tip: remote_tip.clone(),
1053            evidence_refs: observation.refs.forge.clone(),
1054        },
1055    };
1056    let mut plan = Plan {
1057        schema: PLAN_SCHEMA.into(),
1058        identity: Identity {
1059            plan_id: String::new(),
1060            created_at: clock.to_owned(),
1061            engine_version: engine_version.to_owned(),
1062        },
1063        classification,
1064        findings,
1065        desired_state: DesiredState {
1066            intent,
1067            selector: selector.to_owned(),
1068            release: ResolvedRelease {
1069                version: candidate_version.clone(),
1070                venue: candidate.venue.to_owned(),
1071                payload_sha256: candidate.manifest.payload_sha256.clone(),
1072                payload_schema: bundle_schema,
1073            },
1074            configuration,
1075            unresolved: resolution.unresolved.clone(),
1076        },
1077        observed_state: ObservedState {
1078            repository: Repository {
1079                target: observation.target.clone(),
1080                git: observation.git,
1081                tags: observation.facts.tags,
1082                long_lived_branches: observation.facts.long_lived_branches.clone(),
1083                release_markers: observation.facts.release_markers.clone(),
1084                collisions: observation.facts.collisions.clone(),
1085                tech: observation.tech.clone(),
1086                forge: observation.forge_name.clone(),
1087                repo: observation.repo.clone(),
1088                verdict,
1089                evidence_refs: observation.refs.repository.clone(),
1090            },
1091            installation: Installation {
1092                record: record_view,
1093                configuration: configuration_view,
1094                destinations,
1095                evidence_refs: installation_refs,
1096            },
1097            host: Host {
1098                engine_version: engine_version.to_owned(),
1099                pin: observation.pin.clone(),
1100                evidence_refs: observation
1101                    .refs
1102                    .host
1103                    .iter()
1104                    .chain(observation.refs.pin.iter())
1105                    .cloned()
1106                    .collect(),
1107            },
1108            forge: forge_view,
1109        },
1110        release: Release {
1111            candidate: BundleIdentity {
1112                version: candidate_version,
1113                payload_sha256: candidate.manifest.payload_sha256.clone(),
1114                payload_schema: bundle_schema,
1115                artifacts: candidate.manifest.artifacts.len(),
1116                evidence_refs: vec![candidate_ref],
1117            },
1118            verification: candidate.verification,
1119            baseline: baseline_state,
1120            compatibility: evaluated.facts,
1121            guidance: selected_guidance.guidance,
1122        },
1123        operations,
1124        preconditions,
1125        decisions,
1126        postconditions,
1127        evidence: observation.ledger.into_items(),
1128        readiness,
1129        input_fingerprint: Digest::of(b""),
1130    };
1131    plan.input_fingerprint = fingerprint::compute(&plan);
1132    plan.identity.plan_id = fingerprint::plan_id(&plan.input_fingerprint, clock);
1133    let withheld = resolution
1134        .nix_withheld
1135        .as_ref()
1136        .map(|(set, reason)| {
1137            set.iter()
1138                .map(|path| landing::Withheld {
1139                    path: path.clone(),
1140                    reason: reason.clone(),
1141                })
1142                .collect()
1143        })
1144        .unwrap_or_default();
1145    Ok(Planned {
1146        plan,
1147        blobs,
1148        outcomes,
1149        config,
1150        withheld,
1151    })
1152}
1153
1154/// The word the fronts print for one compared destination.
1155fn disposition(
1156    c: &Compared<'_>,
1157    recorded: Option<&Manifest>,
1158    disk: Option<&Vec<u8>>,
1159) -> Disposition {
1160    if c.conflict {
1161        return if c.missing {
1162            Disposition::Missing
1163        } else {
1164            Disposition::Conflict
1165        };
1166    }
1167    if c.write {
1168        return Disposition::Write;
1169    }
1170    let named = recorded.and_then(|record| record.file(&c.entry.destination));
1171    match (named, c.entry.kind) {
1172        (Some(_), Kind::Rendered) => Disposition::Unchanged,
1173        (Some(_), Kind::Seeded) => {
1174            let at_baseline = disk
1175                .map(|bytes| Digest::of(bytes))
1176                .is_some_and(|digest| Some(&digest) == c.record.baseline_sha256.as_ref());
1177            if at_baseline {
1178                Disposition::Unchanged
1179            } else {
1180                Disposition::Drift
1181            }
1182        }
1183        (Some(_), Kind::State) => Disposition::State,
1184        (None, _) => {
1185            if disk.is_some_and(|bytes| *bytes == c.entry.rendered) {
1186                Disposition::Unchanged
1187            } else {
1188                Disposition::Kept
1189            }
1190        }
1191    }
1192}
1193
1194/// The comparison's outcome for one destination.
1195struct Outcome {
1196    write: bool,
1197    conflict: bool,
1198    missing: bool,
1199    record: FileRecord,
1200}
1201
1202/// A destination on a target with no record: it lands as `rk init`
1203/// lands it. A differing `rendered` destination is a conflict, a
1204/// differing `seeded` or `state` one is the target's and is kept.
1205fn compare_fresh(entry: &Entry, disk: Option<&[u8]>) -> Outcome {
1206    let (write, conflict, landed) = match disk {
1207        None => (true, false, entry.rendered.clone()),
1208        Some(bytes) if bytes == entry.rendered => (false, false, bytes.to_vec()),
1209        Some(bytes) if entry.kind != Kind::Rendered => (false, false, bytes.to_vec()),
1210        Some(_) => (false, true, entry.rendered.clone()),
1211    };
1212    Outcome {
1213        write,
1214        conflict,
1215        missing: false,
1216        record: FileRecord {
1217            destination: entry.destination.clone(),
1218            kind: entry.kind,
1219            sha256: Digest::of(&landed),
1220            baseline_sha256: baseline_digest(entry),
1221        },
1222    }
1223}
1224
1225/// A destination on a recorded target: the three digests decide it, in
1226/// the shape `rk upgrade` has always decided by.
1227fn compare_recorded(entry: &Entry, recorded: Option<&FileRecord>, disk: Option<&[u8]>) -> Outcome {
1228    let Some(recorded) = recorded else {
1229        return compare_fresh(entry, disk);
1230    };
1231    let candidate_record = |sha256: Digest| FileRecord {
1232        destination: entry.destination.clone(),
1233        kind: entry.kind,
1234        sha256,
1235        baseline_sha256: baseline_digest(entry),
1236    };
1237    // A seeded file this payload reclassifies as rendered claims ownership
1238    // of a file the target may have tuned; only untouched bytes permit it.
1239    if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
1240        let untouched =
1241            disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
1242        return Outcome {
1243            write: untouched,
1244            conflict: !untouched,
1245            missing: disk.is_none(),
1246            record: candidate_record(Digest::of(&entry.rendered)),
1247        };
1248    }
1249    match entry.kind {
1250        Kind::Rendered => match disk {
1251            Some(bytes) if Digest::of(bytes) == recorded.sha256 => Outcome {
1252                write: bytes != entry.rendered,
1253                conflict: false,
1254                missing: false,
1255                record: candidate_record(Digest::of(&entry.rendered)),
1256            },
1257            Some(bytes) if bytes == entry.rendered => Outcome {
1258                write: false,
1259                conflict: false,
1260                missing: false,
1261                record: candidate_record(Digest::of(&entry.rendered)),
1262            },
1263            other => Outcome {
1264                write: false,
1265                conflict: true,
1266                missing: other.is_none(),
1267                record: candidate_record(Digest::of(&entry.rendered)),
1268            },
1269        },
1270        Kind::Seeded => {
1271            // Never written; the record follows the target's bytes and
1272            // keeps the baseline it tunes away from.
1273            let baseline = if recorded.kind == Kind::Rendered {
1274                Some(recorded.sha256.clone())
1275            } else {
1276                recorded.baseline_sha256.clone()
1277            };
1278            let sha256 = disk.map_or_else(|| recorded.sha256.clone(), Digest::of);
1279            Outcome {
1280                write: false,
1281                conflict: false,
1282                missing: false,
1283                record: FileRecord {
1284                    destination: entry.destination.clone(),
1285                    kind: entry.kind,
1286                    sha256,
1287                    baseline_sha256: baseline,
1288                },
1289            }
1290        }
1291        Kind::State => Outcome {
1292            write: false,
1293            conflict: false,
1294            missing: false,
1295            record: FileRecord {
1296                destination: entry.destination.clone(),
1297                kind: entry.kind,
1298                sha256: recorded.sha256.clone(),
1299                baseline_sha256: None,
1300            },
1301        },
1302    }
1303}
1304
1305/// The digest a fresh record keeps as a destination's baseline.
1306fn baseline_digest(entry: &Entry) -> Option<Digest> {
1307    match entry.kind {
1308        Kind::State => None,
1309        Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
1310    }
1311}
1312
1313/// The recorded release's bytes for one destination, where the baseline
1314/// bundle carries the artifact the record's baseline digest names.
1315fn baseline_bytes(source: &dyn ReleaseSource, record: &Manifest, entry: &Entry) -> Option<Vec<u8>> {
1316    let digest = record.file(&entry.destination)?.baseline_sha256.as_ref()?;
1317    source.blob(digest).ok()
1318}
1319
1320/// The record an apply writes: the candidate's identity, the resolved
1321/// parameters, every compared destination, and the candidate's pins,
1322/// with the first landing's instant and origin preserved where a record
1323/// exists.
1324fn planned_manifest<'a>(
1325    candidate: &Candidate<'_>,
1326    params: &landing::Params,
1327    recorded: Option<&Manifest>,
1328    intent: Intent,
1329    clock: &str,
1330    files: impl Iterator<Item = &'a FileRecord>,
1331) -> Manifest {
1332    let pins = crate::release::read(candidate.source, &candidate.manifest, "versions.toml")
1333        .map(|bytes| crate::registry::pins_in(&String::from_utf8_lossy(&bytes)))
1334        .unwrap_or_default()
1335        .into_iter()
1336        .filter(|pin| pin.used_by.iter().any(|user| user == params.tech()))
1337        .map(|pin| (pin.name, pin.version))
1338        .collect();
1339    Manifest {
1340        schema_version: manifest::SCHEMA_VERSION,
1341        rk_version: candidate.manifest.release_kit_version.clone(),
1342        payload_sha256: candidate.manifest.payload_sha256.clone(),
1343        origin: recorded.map_or_else(
1344            || {
1345                if intent == Intent::Adopt {
1346                    "adopt".to_owned()
1347                } else {
1348                    "init".to_owned()
1349                }
1350            },
1351            |record| record.origin.clone(),
1352        ),
1353        tech: params.tech().to_owned(),
1354        forge: params.forge().to_owned(),
1355        landed_at: recorded.map_or_else(|| clock.to_owned(), |record| record.landed_at.clone()),
1356        parameters: Parameters {
1357            repo: params.repo().to_owned(),
1358            workflow: params.workflow(),
1359            style: params.style(),
1360            nix: params.nix(),
1361            trunk: params.trunk().to_owned(),
1362            line_prefix: params.line_prefix().to_owned(),
1363            security_contact: params.security_contact().to_owned(),
1364            security_response: params.security_response().to_owned(),
1365        },
1366        files: files
1367            .map(|file| FileRecord {
1368                destination: file.destination.clone(),
1369                kind: file.kind,
1370                sha256: file.sha256.clone(),
1371                baseline_sha256: file.baseline_sha256.clone(),
1372            })
1373            .collect(),
1374        pins,
1375    }
1376}
1377
1378/// A version with its leading `v` removed, so a manager's recorded form
1379/// compares against the crate's.
1380fn trim_v(version: &str) -> &str {
1381    version.strip_prefix('v').unwrap_or(version)
1382}