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