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