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