Skip to main content

release_kit/plan/
mod.rs

1//! The plan: one typed, immutable document that is the input to every
2//! landing write.
3//!
4//! The document keeps five kinds apart, and an addition inside one kind
5//! is additive. Evidence is what was observed. Analysis is what the
6//! engine derived from it: the operations, the compatibility, the
7//! guidance. Policy is the requirement each precondition carries.
8//! Decisions are workflow state the operator owns. Postconditions are
9//! what proves completion. `rk reconcile plan` computes one and prints
10//! it; nothing here writes into a target.
11
12pub mod apply;
13pub mod classify;
14pub mod compatibility;
15pub mod evidence;
16pub mod fingerprint;
17pub mod gather;
18pub mod guidance;
19pub mod lock;
20pub mod operation;
21pub mod planner;
22pub mod readiness;
23pub mod store;
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29pub use classify::{Classification, Finding, Verdict};
30pub use evidence::{EvidenceItem, EvidenceKind};
31pub use operation::Operation;
32pub use readiness::{Evaluation, Precondition, Readiness, Requirement};
33
34use crate::digest::Digest;
35use crate::landing::Kind;
36
37/// The version of the plan's shape.
38pub const PLAN_SCHEMA: &str = "rk.plan/3";
39
40/// What the caller asked the plan to be: the open reconciliation, or one
41/// of the three fronts, each of which fixes what the plan may contain.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum Intent {
45    /// `rk reconcile plan`: the classification decides.
46    Reconcile,
47    /// `rk init`: a first landing, refused over a record.
48    Setup,
49    /// `rk upgrade`: a recorded target takes the candidate, refused
50    /// without a record.
51    Upgrade,
52    /// `rk adopt`: the record and the configuration alone, every
53    /// destination verified and none written.
54    Adopt,
55}
56
57impl Intent {
58    /// The wire form, identical to the serde rendering.
59    #[must_use]
60    pub const fn as_str(self) -> &'static str {
61        match self {
62            Self::Reconcile => "reconcile",
63            Self::Setup => "setup",
64            Self::Upgrade => "upgrade",
65            Self::Adopt => "adopt",
66        }
67    }
68}
69
70/// The plan, whole.
71#[derive(Debug, Serialize, Deserialize)]
72pub struct Plan {
73    /// The shape version of this document.
74    pub schema: std::borrow::Cow<'static, str>,
75    /// Who computed it, when, under which id.
76    pub identity: Identity,
77    /// Which procedure this plan is.
78    pub classification: Classification,
79    /// What the classification compresses.
80    pub findings: Vec<Finding>,
81    /// What the target is asked to converge toward.
82    pub desired_state: DesiredState,
83    /// What the target was found to be.
84    pub observed_state: ObservedState,
85    /// The candidate bundle and what is known about it.
86    pub release: Release,
87    /// The typed changes, in apply order.
88    pub operations: Vec<Operation>,
89    /// Each with its requirement and its evaluation.
90    pub preconditions: Vec<Precondition>,
91    /// The questions the operator owns, with their selected answers.
92    pub decisions: Vec<Decision>,
93    /// The typed checks an apply runs at the end and reports.
94    pub postconditions: Vec<Postcondition>,
95    /// Every observed value, cited by the fields above.
96    pub evidence: Vec<EvidenceItem>,
97    /// Whether the plan may be applied.
98    pub readiness: Readiness,
99    /// One canonical digest over the semantic inputs.
100    pub input_fingerprint: Digest,
101}
102
103/// Who computed the plan, when, and under which id.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Identity {
106    /// Derived from the fingerprint and the creation instant, so two
107    /// plans over the same inputs are distinguishable and one plan is
108    /// not stored twice by accident.
109    pub plan_id: String,
110    /// The instant the plan was computed, RFC 3339.
111    pub created_at: String,
112    /// The engine that computed it.
113    pub engine_version: String,
114}
115
116/// What the target is asked to converge toward.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DesiredState {
119    /// What the caller asked the plan to be.
120    pub intent: Intent,
121    /// The selector as the operator gave it: `embedded`, `latest`, or an
122    /// exact version.
123    pub selector: String,
124    /// What the selector resolved to, once, frozen here.
125    pub release: ResolvedRelease,
126    /// The landing configuration the projection renders under, or the
127    /// reason none resolved.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub configuration: Option<Configuration>,
130    /// Why the configuration did not resolve, where it did not.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub unresolved: Option<String>,
133}
134
135/// One exact release, resolved at plan time and never again.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ResolvedRelease {
138    /// The exact version.
139    pub version: String,
140    /// Where it was read from: `embedded`, `crates`, or `directory`.
141    pub venue: String,
142    /// The bundle's aggregate digest.
143    pub payload_sha256: Digest,
144    /// The bundle's protocol version.
145    pub payload_schema: u32,
146}
147
148/// The landing parameters, resolved, with the layer each one came from.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Configuration {
151    /// The payload binding.
152    pub tech: String,
153    /// The forge.
154    pub forge: String,
155    /// The project path on the forge.
156    pub repo: String,
157    /// The working-copy mode.
158    pub workflow: String,
159    /// The release style, where one is answered.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub style: Option<String>,
162    /// Whether the landing carries the Nix capability.
163    pub nix: bool,
164    /// The one permanent branch.
165    pub trunk: String,
166    /// The release-line prefix.
167    pub line_prefix: String,
168    /// The security contact the policy names, empty for the forge's own.
169    pub security_contact: String,
170    /// The acknowledgment window the policy promises.
171    pub security_response: String,
172    /// Which layer answered each parameter: `flag`, `configuration`,
173    /// `record`, `detected`, or `default`.
174    pub sources: BTreeMap<String, String>,
175    /// The evidence the resolution read.
176    pub evidence_refs: Vec<String>,
177}
178
179/// What the target was found to be.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ObservedState {
182    /// The repository's own state.
183    pub repository: Repository,
184    /// What release-kit landed there, as far as the disk says.
185    pub installation: Installation,
186    /// The engine and the host.
187    pub host: Host,
188    /// What the forge said, where it was asked.
189    pub forge: ForgeState,
190}
191
192/// The repository's own state, read off the disk and git.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct Repository {
195    /// The target directory.
196    pub target: String,
197    /// Whether the target is a git repository.
198    pub git: bool,
199    /// How many tags it holds.
200    pub tags: usize,
201    /// Long-lived branches beside the trunk.
202    pub long_lived_branches: Vec<String>,
203    /// Other tools' release markers present.
204    pub release_markers: Vec<String>,
205    /// Payload destinations already present.
206    pub collisions: Vec<String>,
207    /// The technology the version file names, where one is found.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub tech: Option<String>,
210    /// The forge the origin remote maps to, where one is recognized.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub forge: Option<String>,
213    /// The project path from the origin remote, where one exists.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub repo: Option<String>,
216    /// The corpus verdict the facts above earn.
217    pub verdict: Verdict,
218    /// The evidence these facts rest on.
219    pub evidence_refs: Vec<String>,
220}
221
222/// What release-kit landed at the target.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct Installation {
225    /// The landing record.
226    pub record: RecordState,
227    /// The committed configuration.
228    pub configuration: ConfigurationState,
229    /// Every destination the candidate or the record names, as found.
230    pub destinations: Vec<Destination>,
231    /// The evidence the installation rests on.
232    pub evidence_refs: Vec<String>,
233}
234
235/// The landing record, as found.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(tag = "state", rename_all = "kebab-case")]
238pub enum RecordState {
239    /// No record at the target.
240    Absent,
241    /// A record this engine read.
242    Present {
243        /// The binary that wrote it.
244        rk_version: String,
245        /// The payload that landed.
246        payload_sha256: Digest,
247        /// The record's schema.
248        schema_version: u64,
249        /// How the record came to exist.
250        origin: String,
251        /// The digest of the record's bytes.
252        sha256: Digest,
253    },
254    /// A record this engine could not read.
255    Invalid {
256        /// Why.
257        reason: String,
258    },
259}
260
261/// The committed configuration, as found.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ConfigurationState {
264    /// Whether `.release-kit/config.toml` exists.
265    pub present: bool,
266    /// The digest of its bytes, where present.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub sha256: Option<Digest>,
269    /// Why it did not read, where it did not.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub invalid: Option<String>,
272    /// Keys whose configured answers the record has yet to take up.
273    pub pending: Vec<String>,
274}
275
276/// One destination, as found.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Destination {
279    /// The destination, relative to the target.
280    pub path: String,
281    /// Whether the file, or the marked block, is present.
282    pub present: bool,
283    /// The digest of what is there, where present.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub sha256: Option<Digest>,
286    /// The kind the record declares for it, where the record names it.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub recorded_kind: Option<Kind>,
289}
290
291/// The engine and the host.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Host {
294    /// This engine's version.
295    pub engine_version: String,
296    /// The pin the wired manager records for `rk`, where one does.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub pin: Option<PinState>,
299    /// The evidence the host facts rest on.
300    pub evidence_refs: Vec<String>,
301}
302
303/// The `rk` pin a tool manager records.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct PinState {
306    /// The manager.
307    pub manager: String,
308    /// The file that records it.
309    pub file: String,
310    /// The version, as the manager records it.
311    pub version: String,
312}
313
314/// What the forge said, where it was asked.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "state", rename_all = "kebab-case")]
317pub enum ForgeState {
318    /// The forge was not asked, and the reason says why.
319    NotObserved {
320        /// Why.
321        reason: String,
322    },
323    /// The forge was asked.
324    Observed {
325        /// The trunk the read asked about.
326        trunk: String,
327        /// The trunk's tip at the remote, where it has one.
328        #[serde(skip_serializing_if = "Option::is_none")]
329        remote_tip: Option<String>,
330        /// The evidence the read produced.
331        evidence_refs: Vec<String>,
332    },
333}
334
335/// The candidate bundle and what is known about it.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct Release {
338    /// The candidate's identity.
339    pub candidate: BundleIdentity,
340    /// How the candidate was verified.
341    pub verification: Verification,
342    /// The recorded release's bundle, for the three-way comparison.
343    pub baseline: BaselineState,
344    /// What the engine can say about reading this bundle.
345    pub compatibility: Compatibility,
346    /// The guidance the bundle carries for this target.
347    pub guidance: Guidance,
348}
349
350/// One bundle's identity.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct BundleIdentity {
353    /// The release's version.
354    pub version: String,
355    /// The aggregate digest.
356    pub payload_sha256: Digest,
357    /// The protocol version.
358    pub payload_schema: u32,
359    /// How many artifacts the bundle carries.
360    pub artifacts: usize,
361    /// The evidence the identity rests on.
362    pub evidence_refs: Vec<String>,
363}
364
365/// How a bundle was verified.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(tag = "method", rename_all = "kebab-case")]
368pub enum Verification {
369    /// The bundle is the one compiled into this engine.
370    Embedded,
371    /// The archive digested to the registry's checksum.
372    RegistryChecksum {
373        /// The checksum the index named.
374        cksum: Digest,
375    },
376    /// A directory laid out as a bundle, read as is.
377    Directory,
378}
379
380/// The recorded release's bundle, as read for the baseline.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382#[serde(tag = "state", rename_all = "kebab-case")]
383pub enum BaselineState {
384    /// No record, so no baseline is needed.
385    NotNeeded,
386    /// The recorded payload is the one compiled into this engine.
387    Embedded,
388    /// The recorded release's bundle was read from the release cache.
389    Cached {
390        /// The recorded version.
391        version: String,
392    },
393    /// The recorded release's bundle could not be read, and the reason
394    /// says why.
395    NotObserved {
396        /// Why.
397        reason: String,
398    },
399}
400
401/// What the engine can say about landing this bundle here.
402///
403/// The protocol axis, and the four axes the bundle declares beyond it.
404/// The facts live here; the preconditions carry each axis's requirement
405/// and evaluation.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct Compatibility {
408    /// The engine's protocol version.
409    pub engine_schema: u32,
410    /// The bundle's protocol version.
411    pub bundle_schema: u32,
412    /// Whether the engine reads the bundle.
413    pub readable: bool,
414    /// The oldest engine the bundle names, where it names one.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub engine_minimum: Option<String>,
417    /// The generator the binding's committed artifact needs, where the
418    /// technology has one.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub generator: Option<GeneratorFact>,
421    /// The forge floor the landed files rest on, where the bundle declares
422    /// one for the configured forge.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub forge_floor: Option<ForgeFloor>,
425    /// The releases between the record and the candidate that a landing
426    /// must pass through.
427    pub intermediate: Vec<IntermediateFact>,
428    /// The evidence the axes rest on.
429    pub evidence_refs: Vec<String>,
430}
431
432/// The generator axis, as observed.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct GeneratorFact {
435    /// The tool, as `versions.toml` names it.
436    pub name: String,
437    /// The version the candidate bundle pins.
438    pub pin: String,
439    /// The committed artifact it regenerates.
440    pub artifact: String,
441    /// The version found on the host, where one was.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub host: Option<String>,
444}
445
446/// The forge axis, as declared and observed.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct ForgeFloor {
449    /// The forge.
450    pub forge: String,
451    /// The floor the bundle declares.
452    pub minimum: String,
453    /// The version the forge reported, where it was asked.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub observed: Option<String>,
456}
457
458/// One release the landing must pass through.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct IntermediateFact {
461    /// The version.
462    pub version: String,
463    /// Why it cannot be skipped.
464    pub reason: String,
465}
466
467/// The guidance the bundle carries for this target.
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct Guidance {
470    /// How much of the interval the bundle describes.
471    pub coverage: Coverage,
472    /// The releases the selection spans, where a record bounds it.
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub interval: Option<Interval>,
475    /// The steps that concern a destination this target has, in version
476    /// order.
477    pub steps: Vec<GuidanceStep>,
478    /// How many steps in the interval concern no destination here.
479    pub excluded: usize,
480    /// The evidence the selection rests on.
481    pub evidence_refs: Vec<String>,
482}
483
484/// How much of the interval the bundle describes. `covered` with no step
485/// is "no applicable steps", and it is not `unavailable`.
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(tag = "state", rename_all = "kebab-case")]
488pub enum Coverage {
489    /// No record bounds an interval, so there is nothing to describe.
490    NotNeeded,
491    /// Every release in the interval is described.
492    Covered,
493    /// The interval reaches below the release the bundle describes from.
494    Partial {
495        /// The release above which the bundle describes every release.
496        since: String,
497    },
498    /// The bundle carries no guidance at all.
499    Unavailable,
500}
501
502/// The releases a selection spans: above `from`, up to and including
503/// `to`.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct Interval {
506    /// The recorded release.
507    pub from: String,
508    /// The candidate release.
509    pub to: String,
510}
511
512/// One release's step, as the target needs it.
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct GuidanceStep {
515    /// The release that introduced the change.
516    pub version: String,
517    /// The file's heading.
518    pub title: String,
519    /// The landed paths it concerns, all of them, as authored.
520    pub destinations: Vec<String>,
521    /// `operator-step` or `plan-operation`.
522    pub action: String,
523    /// The authored text below the fields.
524    pub body: String,
525}
526
527/// One question the operator owns.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct Decision {
530    /// A stable id that survives re-planning.
531    pub id: String,
532    /// The question, one line.
533    pub question: String,
534    /// The answers, each with its consequence.
535    pub choices: Vec<Choice>,
536    /// The answer selected, where one is.
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub selected: Option<String>,
539}
540
541/// One answer to a decision.
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct Choice {
544    /// The answer word.
545    pub answer: String,
546    /// What selecting it means.
547    pub consequence: String,
548}
549
550/// Every decision this engine asks, with the closed set of answers each
551/// one takes.
552///
553/// A decision is a question the operator owns, and its choices are the
554/// whole of what answers it. The catalogue is the one place that pairing
555/// lives, so the parse that reads `--decide` and the evaluation that
556/// judges a stored answer agree by construction. A decision the planner
557/// adds without an entry here fails
558/// [`every_decision_the_planner_asks_is_in_the_catalogue`].
559pub const DECISION_CHOICES: [(&str, &[&str]); 6] = [
560    ("workflow-mode", &["worktree", "branches"]),
561    ("release-style", &["trunk", "lines"]),
562    ("release-activity", &["history", "migrate"]),
563    ("partial-baseline", &["accept", "fetch"]),
564    (guidance::PARTIAL_GUIDANCE_DECISION, &["accept"]),
565    (compatibility::PIN_MANAGER_DECISION, &["wire", "host"]),
566];
567
568/// The answers one decision takes, where the catalogue names it.
569#[must_use]
570pub fn decision_choices(id: &str) -> Option<&'static [&'static str]> {
571    DECISION_CHOICES
572        .iter()
573        .find(|(known, _)| *known == id)
574        .map(|(_, choices)| *choices)
575}
576
577/// Whether `answer` is one this decision declares.
578///
579/// An id the catalogue does not name answers `false`: an unknown
580/// decision has no answer that satisfies it.
581#[must_use]
582pub fn decision_answered(id: &str, answer: Option<&str>) -> bool {
583    match (decision_choices(id), answer) {
584        (Some(choices), Some(answer)) => choices.contains(&answer),
585        _ => false,
586    }
587}
588
589/// One typed check an apply runs at the end and reports.
590#[derive(Debug, Clone, Serialize, Deserialize)]
591#[serde(tag = "check", rename_all = "kebab-case")]
592pub enum Postcondition {
593    /// The record reads back at the planned digest.
594    RecordReadsBack {
595        /// The planned digest.
596        sha256: Digest,
597    },
598    /// A destination holds the planned bytes.
599    DestinationHolds {
600        /// The destination.
601        path: String,
602        /// The planned digest.
603        sha256: Digest,
604    },
605    /// `rk status --check` exits 0.
606    StatusCheckClean,
607    /// The wired manager records the planned version.
608    PinReads {
609        /// The manager.
610        manager: String,
611        /// The planned version.
612        version: String,
613    },
614}
615
616/// One request to compute a plan, as the store keeps it beside the plan
617/// so an apply can compute the same plan again and compare.
618#[derive(Debug, Clone, Serialize, Deserialize)]
619pub struct PlanRequest {
620    /// The target, absolute and symlink-resolved, so a stored plan names
621    /// one checkout and never whichever directory an apply runs in.
622    pub target: camino::Utf8PathBuf,
623    /// What the caller asked the plan to be.
624    pub intent: Intent,
625    /// The selector as given: `embedded`, `latest`, or an exact version.
626    pub selector: String,
627    /// Whether a recorded release the cache does not hold is fetched.
628    pub fetch: bool,
629    /// Whether the forge is read.
630    pub observe_forge: bool,
631    /// The explicit answers.
632    pub flags: gather::Flags,
633    /// The decisions selected, by id.
634    pub decisions: BTreeMap<String, String>,
635}
636
637impl PlanRequest {
638    /// The same request with its target resolved to an absolute,
639    /// symlink-free path.
640    ///
641    /// Every construction site takes this before the request reaches the
642    /// planner or the store. A relative target would otherwise resolve
643    /// against whichever directory an apply runs in, so a plan approved
644    /// against one checkout could land in another whose content happens
645    /// to match, which the fingerprint alone cannot catch.
646    ///
647    /// # Errors
648    ///
649    /// [`RkError::Usage`] where the target does not exist or cannot be
650    /// resolved, and [`RkError::Other`] where the resolved path is not
651    /// valid UTF-8.
652    pub fn canonicalized(mut self) -> Result<Self, crate::error::RkError> {
653        self.target = canonical_target(&self.target)?;
654        Ok(self)
655    }
656}
657
658/// One target directory as an absolute, symlink-free path.
659///
660/// # Errors
661///
662/// [`RkError::Usage`] where the path does not exist or cannot be
663/// resolved, and [`RkError::Other`] where it is not valid UTF-8.
664pub fn canonical_target(
665    target: &camino::Utf8Path,
666) -> Result<camino::Utf8PathBuf, crate::error::RkError> {
667    let resolved = std::fs::canonicalize(target).map_err(|error| {
668        crate::error::RkError::Usage(format!(
669            "the target {target} does not resolve to a directory: {error}"
670        ))
671    })?;
672    camino::Utf8PathBuf::from_path_buf(resolved).map_err(|path| {
673        crate::error::RkError::Other(anyhow::anyhow!(
674            "the target resolves to {}, which is not valid UTF-8",
675            path.display()
676        ))
677    })
678}
679
680/// A computed plan with the bytes its operations name, and what the
681/// three-way comparison decided per destination, for the fronts that
682/// render a per-file report.
683#[derive(Debug)]
684pub struct Planned {
685    /// The plan.
686    pub plan: Plan,
687    /// Every byte the plan names, by digest: what an operation writes,
688    /// what a destination holds now, and the baseline where it was read.
689    pub blobs: BTreeMap<Digest, Vec<u8>>,
690    /// What the comparison decided for each projected destination, in
691    /// projection order.
692    pub outcomes: Vec<DestinationOutcome>,
693    /// The configuration an apply writes, where the parameters resolved.
694    pub config: Option<crate::config::Plan>,
695    /// The Nix destinations withheld at this target, each with why.
696    pub withheld: Vec<crate::landing::Withheld>,
697}
698
699/// What the three-way comparison decided for one projected destination.
700#[derive(Debug, Clone, PartialEq, Eq)]
701pub struct DestinationOutcome {
702    /// The destination, relative to the target.
703    pub path: String,
704    /// The kind the candidate declares.
705    pub kind: Kind,
706    /// Whether the record names it.
707    pub recorded: bool,
708    /// What happens to it.
709    pub disposition: Disposition,
710}
711
712/// The closed set of things the comparison decides for a destination.
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub enum Disposition {
715    /// The candidate's bytes are written.
716    Write,
717    /// The destination already holds what the candidate would write, or
718    /// what the record left there.
719    Unchanged,
720    /// The target's own bytes stay: a seeded or state file it tuned.
721    Kept,
722    /// A recorded seeded file moved away from its baseline and stays.
723    Drift,
724    /// A recorded state file, never compared.
725    State,
726    /// The target edited a file release-kit owns.
727    Conflict,
728    /// The record names a file the disk does not hold.
729    Missing,
730}
731
732impl Disposition {
733    /// The word a report prints.
734    #[must_use]
735    pub const fn as_str(self) -> &'static str {
736        match self {
737            Self::Write => "write",
738            Self::Unchanged => "unchanged",
739            Self::Kept => "kept",
740            Self::Drift => "drift",
741            Self::State => "state",
742            Self::Conflict => "conflict",
743            Self::Missing => "missing",
744        }
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use std::collections::BTreeMap;
751
752    use super::{
753        BaselineState, BundleIdentity, Choice, Classification, Compatibility, Configuration,
754        ConfigurationState, Coverage, DECISION_CHOICES, Decision, DesiredState, Destination,
755        Evaluation, ForgeFloor, ForgeState, GeneratorFact, Guidance, GuidanceStep, Host, Identity,
756        Installation, Intent, IntermediateFact, Interval, Operation, PLAN_SCHEMA, PinState, Plan,
757        Postcondition, Precondition, Readiness, RecordState, Release, Repository, Requirement,
758        ResolvedRelease, Verdict, Verification, decision_answered, decision_choices,
759    };
760    use crate::digest::Digest;
761    use crate::landing::Kind;
762    use crate::plan::classify::Finding;
763    use crate::plan::evidence::{EvidenceItem, EvidenceKind};
764
765    /// The complete `rk.plan/3` shape, every section present, held by
766    /// snapshot: a field rename or removal fails here and becomes a
767    /// deliberate schema bump.
768    #[test]
769    #[allow(
770        clippy::too_many_lines,
771        reason = "the snapshot builds every section of the plan once, and cutting it would hide a section from the one test that holds the shape"
772    )]
773    fn the_plan_schema_is_versioned_and_snapshot_tested() {
774        let a = Digest::of(b"a");
775        let b = Digest::of(b"b");
776        let plan = Plan {
777            schema: PLAN_SCHEMA.into(),
778            identity: Identity {
779                plan_id: "0123456789abcdef".into(),
780                created_at: "2026-01-01T00:00:00Z".into(),
781                engine_version: "0.0.0".into(),
782            },
783            classification: Classification::Upgrade,
784            findings: vec![Finding {
785                code: "payload-collision".into(),
786                detail: "SECURITY.md".into(),
787            }],
788            desired_state: DesiredState {
789                intent: Intent::Reconcile,
790                selector: "embedded".into(),
791                release: ResolvedRelease {
792                    version: "0.0.0".into(),
793                    venue: "embedded".into(),
794                    payload_sha256: a.clone(),
795                    payload_schema: 1,
796                },
797                configuration: Some(Configuration {
798                    tech: "rust".into(),
799                    forge: "github".into(),
800                    repo: "acme/widget".into(),
801                    workflow: "worktree".into(),
802                    style: Some("trunk".into()),
803                    nix: false,
804                    trunk: "master".into(),
805                    line_prefix: "release/".into(),
806                    security_contact: String::new(),
807                    security_response: "best-effort".into(),
808                    sources: BTreeMap::from([("tech".to_owned(), "record".to_owned())]),
809                    evidence_refs: vec!["record".into()],
810                }),
811                unresolved: None,
812            },
813            observed_state: super::ObservedState {
814                repository: Repository {
815                    target: "/tmp/t".into(),
816                    git: true,
817                    tags: 0,
818                    long_lived_branches: vec![],
819                    release_markers: vec![],
820                    collisions: vec!["SECURITY.md".into()],
821                    tech: Some("rust".into()),
822                    forge: Some("github".into()),
823                    repo: Some("acme/widget".into()),
824                    verdict: Verdict::Brownfield,
825                    evidence_refs: vec!["repository".into()],
826                },
827                installation: Installation {
828                    record: RecordState::Present {
829                        rk_version: "0.0.0".into(),
830                        payload_sha256: a.clone(),
831                        schema_version: 6,
832                        origin: "init".into(),
833                        sha256: b.clone(),
834                    },
835                    configuration: ConfigurationState {
836                        present: true,
837                        sha256: Some(b.clone()),
838                        invalid: None,
839                        pending: vec![],
840                    },
841                    destinations: vec![Destination {
842                        path: "SECURITY.md".into(),
843                        present: true,
844                        sha256: Some(a.clone()),
845                        recorded_kind: Some(Kind::Rendered),
846                    }],
847                    evidence_refs: vec!["record".into(), "configuration".into()],
848                },
849                host: Host {
850                    engine_version: "0.0.0".into(),
851                    pin: Some(PinState {
852                        manager: "mise".into(),
853                        file: "mise.toml".into(),
854                        version: "0.0.0".into(),
855                    }),
856                    evidence_refs: vec!["host".into()],
857                },
858                forge: ForgeState::NotObserved {
859                    reason: "not requested".into(),
860                },
861            },
862            release: Release {
863                candidate: BundleIdentity {
864                    version: "0.0.0".into(),
865                    payload_sha256: a.clone(),
866                    payload_schema: 1,
867                    artifacts: 1,
868                    evidence_refs: vec!["candidate-bundle".into()],
869                },
870                verification: Verification::Embedded,
871                baseline: BaselineState::Embedded,
872                compatibility: Compatibility {
873                    engine_schema: 1,
874                    bundle_schema: 1,
875                    readable: true,
876                    engine_minimum: Some("0.0.0".into()),
877                    generator: Some(GeneratorFact {
878                        name: "cargo-dist".into(),
879                        pin: "0.32.0".into(),
880                        artifact: "dist-workspace.toml".into(),
881                        host: Some("0.32.0".into()),
882                    }),
883                    forge_floor: Some(ForgeFloor {
884                        forge: "gitlab".into(),
885                        minimum: "18.2".into(),
886                        observed: Some("18.2.0".into()),
887                    }),
888                    intermediate: vec![IntermediateFact {
889                        version: "0.0.0".into(),
890                        reason: "the record changed shape".into(),
891                    }],
892                    evidence_refs: vec!["candidate-bundle".into()],
893                },
894                guidance: Guidance {
895                    coverage: Coverage::Partial {
896                        since: "0.0.0".into(),
897                    },
898                    interval: Some(Interval {
899                        from: "0.0.0".into(),
900                        to: "0.0.0".into(),
901                    }),
902                    steps: vec![GuidanceStep {
903                        version: "0.0.0".into(),
904                        title: "release-kit 0.0.0".into(),
905                        destinations: vec![".envrc".into()],
906                        action: "operator-step".into(),
907                        body: "## What to do".into(),
908                    }],
909                    excluded: 1,
910                    evidence_refs: vec!["candidate-bundle".into()],
911                },
912            },
913            operations: vec![Operation::WriteRecord {
914                before: Some(b.clone()),
915                after: a.clone(),
916            }],
917            preconditions: vec![Precondition {
918                id: "record-readable".into(),
919                requirement: Requirement::Required,
920                evaluation: Evaluation::Satisfied,
921                decision: None,
922                evidence_refs: vec!["record".into()],
923            }],
924            decisions: vec![Decision {
925                id: "workflow-mode".into(),
926                question: "which working-copy mode".into(),
927                choices: vec![Choice {
928                    answer: "worktree".into(),
929                    consequence: "every branch in a linked worktree".into(),
930                }],
931                selected: Some("worktree".into()),
932            }],
933            postconditions: vec![Postcondition::RecordReadsBack { sha256: a.clone() }],
934            evidence: vec![EvidenceItem {
935                id: "record".into(),
936                kind: EvidenceKind::Record,
937                producer: "rk".into(),
938                observed_at: "2026-01-01T00:00:00Z".into(),
939                sha256: Some(b.clone()),
940                method: "read".into(),
941            }],
942            readiness: Readiness::Ready,
943            input_fingerprint: a.clone(),
944        };
945        let json = serde_json::to_string(&plan).expect("a plan serializes");
946        let expected = format!(
947            r###"{{"schema":"rk.plan/3","identity":{{"plan_id":"0123456789abcdef","created_at":"2026-01-01T00:00:00Z","engine_version":"0.0.0"}},"classification":"upgrade","findings":[{{"code":"payload-collision","detail":"SECURITY.md"}}],"desired_state":{{"intent":"reconcile","selector":"embedded","release":{{"version":"0.0.0","venue":"embedded","payload_sha256":"{a}","payload_schema":1}},"configuration":{{"tech":"rust","forge":"github","repo":"acme/widget","workflow":"worktree","style":"trunk","nix":false,"trunk":"master","line_prefix":"release/","security_contact":"","security_response":"best-effort","sources":{{"tech":"record"}},"evidence_refs":["record"]}}}},"observed_state":{{"repository":{{"target":"/tmp/t","git":true,"tags":0,"long_lived_branches":[],"release_markers":[],"collisions":["SECURITY.md"],"tech":"rust","forge":"github","repo":"acme/widget","verdict":"brownfield","evidence_refs":["repository"]}},"installation":{{"record":{{"state":"present","rk_version":"0.0.0","payload_sha256":"{a}","schema_version":6,"origin":"init","sha256":"{b}"}},"configuration":{{"present":true,"sha256":"{b}","pending":[]}},"destinations":[{{"path":"SECURITY.md","present":true,"sha256":"{a}","recorded_kind":"rendered"}}],"evidence_refs":["record","configuration"]}},"host":{{"engine_version":"0.0.0","pin":{{"manager":"mise","file":"mise.toml","version":"0.0.0"}},"evidence_refs":["host"]}},"forge":{{"state":"not-observed","reason":"not requested"}}}},"release":{{"candidate":{{"version":"0.0.0","payload_sha256":"{a}","payload_schema":1,"artifacts":1,"evidence_refs":["candidate-bundle"]}},"verification":{{"method":"embedded"}},"baseline":{{"state":"embedded"}},"compatibility":{{"engine_schema":1,"bundle_schema":1,"readable":true,"engine_minimum":"0.0.0","generator":{{"name":"cargo-dist","pin":"0.32.0","artifact":"dist-workspace.toml","host":"0.32.0"}},"forge_floor":{{"forge":"gitlab","minimum":"18.2","observed":"18.2.0"}},"intermediate":[{{"version":"0.0.0","reason":"the record changed shape"}}],"evidence_refs":["candidate-bundle"]}},"guidance":{{"coverage":{{"state":"partial","since":"0.0.0"}},"interval":{{"from":"0.0.0","to":"0.0.0"}},"steps":[{{"version":"0.0.0","title":"release-kit 0.0.0","destinations":[".envrc"],"action":"operator-step","body":"## What to do"}}],"excluded":1,"evidence_refs":["candidate-bundle"]}}}},"operations":[{{"op":"write-record","before":"{b}","after":"{a}"}}],"preconditions":[{{"id":"record-readable","requirement":"required","evaluation":{{"state":"satisfied"}},"evidence_refs":["record"]}}],"decisions":[{{"id":"workflow-mode","question":"which working-copy mode","choices":[{{"answer":"worktree","consequence":"every branch in a linked worktree"}}],"selected":"worktree"}}],"postconditions":[{{"check":"record-reads-back","sha256":"{a}"}}],"evidence":[{{"id":"record","kind":"record","producer":"rk","observed_at":"2026-01-01T00:00:00Z","sha256":"{b}","method":"read"}}],"readiness":"ready","input_fingerprint":"{a}"}}"###
948        );
949        assert_eq!(json, expected);
950    }
951
952    /// Every decision the planner asks carries its choices in the
953    /// catalogue, and every catalogued answer is one a choice declares.
954    ///
955    /// The catalogue is what the parse and the evaluation both read, so
956    /// a decision added to the planner without an entry here would take
957    /// any answer at the parse and satisfy nothing at the evaluation.
958    #[test]
959    fn every_decision_the_planner_asks_is_in_the_catalogue() {
960        let sources = [
961            include_str!("planner.rs"),
962            include_str!("compatibility.rs"),
963            include_str!("guidance.rs"),
964        ];
965        // Every id the catalogue names is asked somewhere, so an entry
966        // does not outlive the decision it describes.
967        for (id, choices) in DECISION_CHOICES {
968            assert!(
969                sources.iter().any(|source| source.contains(id)),
970                "the catalogue names {id} and no module asks it"
971            );
972            assert!(!choices.is_empty(), "{id} declares no answer");
973            for answer in choices {
974                assert!(
975                    decision_answered(id, Some(answer)),
976                    "{id} does not take its own declared answer {answer}"
977                );
978            }
979            assert!(
980                !decision_answered(id, Some("not-a-declared-answer")),
981                "{id} takes an answer it does not declare"
982            );
983        }
984        assert!(decision_choices("not-a-decision").is_none());
985        assert!(!decision_answered("not-a-decision", Some("anything")));
986    }
987}