1use 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
30pub struct Candidate<'a> {
32 pub source: &'a dyn ReleaseSource,
34 pub manifest: ReleaseManifest,
36 pub venue: &'a str,
38 pub verification: Verification,
40}
41
42pub enum Baseline<'a> {
44 NotNeeded,
46 Embedded(&'a dyn ReleaseSource),
48 Cached {
50 version: String,
52 source: &'a dyn ReleaseSource,
54 },
55 NotObserved {
57 reason: String,
59 },
60}
61
62pub struct Inputs<'a> {
64 pub intent: Intent,
66 pub clock: &'a str,
68 pub engine_version: &'a str,
70 pub selector: &'a str,
72 pub candidate: Candidate<'a>,
74 pub baseline: Baseline<'a>,
76 pub observation: Observation,
78 pub resolution: Resolution,
80 pub selected: &'a BTreeMap<String, String>,
82 pub declared: &'a declared::Compatibility,
84 pub guidance_files: &'a [GuidanceFile],
86 pub carries_guidance: bool,
88}
89
90struct Compared<'a> {
92 entry: &'a Entry,
93 before: Option<Digest>,
95 write: bool,
97 conflict: bool,
99 missing: bool,
101 record: FileRecord,
103}
104
105#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 if !operations.is_empty() {
961 postconditions.push(Postcondition::StatusCheckClean);
962 }
963
964 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
1154fn 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
1194struct Outcome {
1196 write: bool,
1197 conflict: bool,
1198 missing: bool,
1199 record: FileRecord,
1200}
1201
1202fn 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
1225fn 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 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 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
1305fn 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
1313fn 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
1320fn 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
1378fn trim_v(version: &str) -> &str {
1381 version.strip_prefix('v').unwrap_or(version)
1382}