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 classify;
13pub mod evidence;
14pub mod fingerprint;
15pub mod gather;
16pub mod operation;
17pub mod planner;
18pub mod readiness;
19
20use std::collections::BTreeMap;
21
22use serde::Serialize;
23
24pub use classify::{Classification, Finding, Verdict};
25pub use evidence::{EvidenceItem, EvidenceKind};
26pub use operation::Operation;
27pub use readiness::{Evaluation, Precondition, Readiness, Requirement};
28
29use crate::digest::Digest;
30use crate::landing::Kind;
31
32/// The version of the plan's shape.
33pub const PLAN_SCHEMA: &str = "rk.plan/1";
34
35/// The plan, whole.
36#[derive(Debug, Serialize)]
37pub struct Plan {
38    /// The shape version of this document.
39    pub schema: &'static str,
40    /// Who computed it, when, under which id.
41    pub identity: Identity,
42    /// Which procedure this plan is.
43    pub classification: Classification,
44    /// What the classification compresses.
45    pub findings: Vec<Finding>,
46    /// What the target is asked to converge toward.
47    pub desired_state: DesiredState,
48    /// What the target was found to be.
49    pub observed_state: ObservedState,
50    /// The candidate bundle and what is known about it.
51    pub release: Release,
52    /// The typed changes, in apply order.
53    pub operations: Vec<Operation>,
54    /// Each with its requirement and its evaluation.
55    pub preconditions: Vec<Precondition>,
56    /// The questions the operator owns, with their selected answers.
57    pub decisions: Vec<Decision>,
58    /// The typed checks an apply runs at the end and reports.
59    pub postconditions: Vec<Postcondition>,
60    /// Every observed value, cited by the fields above.
61    pub evidence: Vec<EvidenceItem>,
62    /// Whether the plan may be applied.
63    pub readiness: Readiness,
64    /// One canonical digest over the semantic inputs.
65    pub input_fingerprint: Digest,
66}
67
68/// Who computed the plan, when, and under which id.
69#[derive(Debug, Clone, Serialize)]
70pub struct Identity {
71    /// Derived from the fingerprint and the creation instant, so two
72    /// plans over the same inputs are distinguishable and one plan is
73    /// not stored twice by accident.
74    pub plan_id: String,
75    /// The instant the plan was computed, RFC 3339.
76    pub created_at: String,
77    /// The engine that computed it.
78    pub engine_version: String,
79}
80
81/// What the target is asked to converge toward.
82#[derive(Debug, Clone, Serialize)]
83pub struct DesiredState {
84    /// The selector as the operator gave it: `embedded`, `latest`, or an
85    /// exact version.
86    pub selector: String,
87    /// What the selector resolved to, once, frozen here.
88    pub release: ResolvedRelease,
89    /// The landing configuration the projection renders under, or the
90    /// reason none resolved.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub configuration: Option<Configuration>,
93    /// Why the configuration did not resolve, where it did not.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub unresolved: Option<String>,
96}
97
98/// One exact release, resolved at plan time and never again.
99#[derive(Debug, Clone, Serialize)]
100pub struct ResolvedRelease {
101    /// The exact version.
102    pub version: String,
103    /// Where it was read from: `embedded`, `crates`, or `directory`.
104    pub venue: String,
105    /// The bundle's aggregate digest.
106    pub payload_sha256: Digest,
107    /// The bundle's protocol version.
108    pub payload_schema: u32,
109}
110
111/// The landing parameters, resolved, with the layer each one came from.
112#[derive(Debug, Clone, Serialize)]
113pub struct Configuration {
114    /// The payload binding.
115    pub tech: String,
116    /// The forge.
117    pub forge: String,
118    /// The project path on the forge.
119    pub repo: String,
120    /// The working-copy mode.
121    pub workflow: String,
122    /// The release style, where one is answered.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub style: Option<String>,
125    /// Whether the landing carries the Nix capability.
126    pub nix: bool,
127    /// The one permanent branch.
128    pub trunk: String,
129    /// The release-line prefix.
130    pub line_prefix: String,
131    /// The security contact the policy names, empty for the forge's own.
132    pub security_contact: String,
133    /// The acknowledgment window the policy promises.
134    pub security_response: String,
135    /// Which layer answered each parameter: `flag`, `configuration`,
136    /// `record`, `detected`, or `default`.
137    pub sources: BTreeMap<String, String>,
138    /// The evidence the resolution read.
139    pub evidence_refs: Vec<String>,
140}
141
142/// What the target was found to be.
143#[derive(Debug, Clone, Serialize)]
144pub struct ObservedState {
145    /// The repository's own state.
146    pub repository: Repository,
147    /// What release-kit landed there, as far as the disk says.
148    pub installation: Installation,
149    /// The engine and the host.
150    pub host: Host,
151    /// What the forge said, where it was asked.
152    pub forge: ForgeState,
153}
154
155/// The repository's own state, read off the disk and git.
156#[derive(Debug, Clone, Serialize)]
157pub struct Repository {
158    /// The target directory.
159    pub target: String,
160    /// Whether the target is a git repository.
161    pub git: bool,
162    /// How many tags it holds.
163    pub tags: usize,
164    /// Long-lived branches beside the trunk.
165    pub long_lived_branches: Vec<String>,
166    /// Other tools' release markers present.
167    pub release_markers: Vec<String>,
168    /// Payload destinations already present.
169    pub collisions: Vec<String>,
170    /// The technology the version file names, where one is found.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub tech: Option<String>,
173    /// The forge the origin remote maps to, where one is recognized.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub forge: Option<String>,
176    /// The project path from the origin remote, where one exists.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub repo: Option<String>,
179    /// The corpus verdict the facts above earn.
180    pub verdict: Verdict,
181    /// The evidence these facts rest on.
182    pub evidence_refs: Vec<String>,
183}
184
185/// What release-kit landed at the target.
186#[derive(Debug, Clone, Serialize)]
187pub struct Installation {
188    /// The landing record.
189    pub record: RecordState,
190    /// The committed configuration.
191    pub configuration: ConfigurationState,
192    /// Every destination the candidate or the record names, as found.
193    pub destinations: Vec<Destination>,
194    /// The evidence the installation rests on.
195    pub evidence_refs: Vec<String>,
196}
197
198/// The landing record, as found.
199#[derive(Debug, Clone, Serialize)]
200#[serde(tag = "state", rename_all = "kebab-case")]
201pub enum RecordState {
202    /// No record at the target.
203    Absent,
204    /// A record this engine read.
205    Present {
206        /// The binary that wrote it.
207        rk_version: String,
208        /// The payload that landed.
209        payload_sha256: Digest,
210        /// The record's schema.
211        schema_version: u64,
212        /// How the record came to exist.
213        origin: String,
214        /// The digest of the record's bytes.
215        sha256: Digest,
216    },
217    /// A record this engine could not read.
218    Invalid {
219        /// Why.
220        reason: String,
221    },
222}
223
224/// The committed configuration, as found.
225#[derive(Debug, Clone, Serialize)]
226pub struct ConfigurationState {
227    /// Whether `.release-kit/config.toml` exists.
228    pub present: bool,
229    /// The digest of its bytes, where present.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub sha256: Option<Digest>,
232    /// Why it did not read, where it did not.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub invalid: Option<String>,
235    /// Keys whose configured answers the record has yet to take up.
236    pub pending: Vec<String>,
237}
238
239/// One destination, as found.
240#[derive(Debug, Clone, Serialize)]
241pub struct Destination {
242    /// The destination, relative to the target.
243    pub path: String,
244    /// Whether the file, or the marked block, is present.
245    pub present: bool,
246    /// The digest of what is there, where present.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub sha256: Option<Digest>,
249    /// The kind the record declares for it, where the record names it.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub recorded_kind: Option<Kind>,
252}
253
254/// The engine and the host.
255#[derive(Debug, Clone, Serialize)]
256pub struct Host {
257    /// This engine's version.
258    pub engine_version: String,
259    /// The pin the wired manager records for `rk`, where one does.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub pin: Option<PinState>,
262    /// The evidence the host facts rest on.
263    pub evidence_refs: Vec<String>,
264}
265
266/// The `rk` pin a tool manager records.
267#[derive(Debug, Clone, Serialize)]
268pub struct PinState {
269    /// The manager.
270    pub manager: String,
271    /// The file that records it.
272    pub file: String,
273    /// The version, as the manager records it.
274    pub version: String,
275}
276
277/// What the forge said, where it was asked.
278#[derive(Debug, Clone, Serialize)]
279#[serde(tag = "state", rename_all = "kebab-case")]
280pub enum ForgeState {
281    /// The forge was not asked, and the reason says why.
282    NotObserved {
283        /// Why.
284        reason: String,
285    },
286    /// The forge was asked.
287    Observed {
288        /// The trunk the read asked about.
289        trunk: String,
290        /// The trunk's tip at the remote, where it has one.
291        #[serde(skip_serializing_if = "Option::is_none")]
292        remote_tip: Option<String>,
293        /// The evidence the read produced.
294        evidence_refs: Vec<String>,
295    },
296}
297
298/// The candidate bundle and what is known about it.
299#[derive(Debug, Clone, Serialize)]
300pub struct Release {
301    /// The candidate's identity.
302    pub candidate: BundleIdentity,
303    /// How the candidate was verified.
304    pub verification: Verification,
305    /// The recorded release's bundle, for the three-way comparison.
306    pub baseline: BaselineState,
307    /// What the engine can say about reading this bundle.
308    pub compatibility: Compatibility,
309    /// The guidance the bundle carries for this target.
310    pub guidance: Guidance,
311}
312
313/// One bundle's identity.
314#[derive(Debug, Clone, Serialize)]
315pub struct BundleIdentity {
316    /// The release's version.
317    pub version: String,
318    /// The aggregate digest.
319    pub payload_sha256: Digest,
320    /// The protocol version.
321    pub payload_schema: u32,
322    /// How many artifacts the bundle carries.
323    pub artifacts: usize,
324    /// The evidence the identity rests on.
325    pub evidence_refs: Vec<String>,
326}
327
328/// How a bundle was verified.
329#[derive(Debug, Clone, Serialize)]
330#[serde(tag = "method", rename_all = "kebab-case")]
331pub enum Verification {
332    /// The bundle is the one compiled into this engine.
333    Embedded,
334    /// The archive digested to the registry's checksum.
335    RegistryChecksum {
336        /// The checksum the index named.
337        cksum: Digest,
338    },
339    /// A directory laid out as a bundle, read as is.
340    Directory,
341}
342
343/// The recorded release's bundle, as read for the baseline.
344#[derive(Debug, Clone, Serialize)]
345#[serde(tag = "state", rename_all = "kebab-case")]
346pub enum BaselineState {
347    /// No record, so no baseline is needed.
348    NotNeeded,
349    /// The recorded payload is the one compiled into this engine.
350    Embedded,
351    /// The recorded release's bundle was read from the release cache.
352    Cached {
353        /// The recorded version.
354        version: String,
355    },
356    /// The recorded release's bundle could not be read, and the reason
357    /// says why.
358    NotObserved {
359        /// Why.
360        reason: String,
361    },
362}
363
364/// What the engine can say about reading this bundle.
365#[derive(Debug, Clone, Serialize)]
366pub struct Compatibility {
367    /// The engine's protocol version.
368    pub engine_schema: u32,
369    /// The bundle's protocol version.
370    pub bundle_schema: u32,
371    /// Whether the engine reads the bundle.
372    pub readable: bool,
373}
374
375/// The guidance the bundle carries for this target.
376#[derive(Debug, Clone, Serialize)]
377pub struct Guidance {
378    /// `not-shipped` until a bundle carries guidance; the coverage words
379    /// grow when one does.
380    pub coverage: &'static str,
381}
382
383/// One question the operator owns.
384#[derive(Debug, Clone, Serialize)]
385pub struct Decision {
386    /// A stable id that survives re-planning.
387    pub id: String,
388    /// The question, one line.
389    pub question: String,
390    /// The answers, each with its consequence.
391    pub choices: Vec<Choice>,
392    /// The answer selected, where one is.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub selected: Option<String>,
395}
396
397/// One answer to a decision.
398#[derive(Debug, Clone, Serialize)]
399pub struct Choice {
400    /// The answer word.
401    pub answer: String,
402    /// What selecting it means.
403    pub consequence: String,
404}
405
406/// One typed check an apply runs at the end and reports.
407#[derive(Debug, Clone, Serialize)]
408#[serde(tag = "check", rename_all = "kebab-case")]
409pub enum Postcondition {
410    /// The record reads back at the planned digest.
411    RecordReadsBack {
412        /// The planned digest.
413        sha256: Digest,
414    },
415    /// A destination holds the planned bytes.
416    DestinationHolds {
417        /// The destination.
418        path: String,
419        /// The planned digest.
420        sha256: Digest,
421    },
422    /// `rk status --check` exits 0.
423    StatusCheckClean,
424    /// The wired manager records the planned version.
425    PinReads {
426        /// The manager.
427        manager: String,
428        /// The planned version.
429        version: String,
430    },
431}
432
433/// A computed plan with the bytes its operations name.
434#[derive(Debug)]
435pub struct Planned {
436    /// The plan.
437    pub plan: Plan,
438    /// Every byte the plan names, by digest: what an operation writes,
439    /// what a destination holds now, and the baseline where it was read.
440    pub blobs: BTreeMap<Digest, Vec<u8>>,
441}
442
443#[cfg(test)]
444mod tests {
445    use std::collections::BTreeMap;
446
447    use super::{
448        BaselineState, BundleIdentity, Choice, Classification, Compatibility, Configuration,
449        ConfigurationState, Decision, DesiredState, Destination, Evaluation, ForgeState, Guidance,
450        Host, Identity, Installation, Operation, PLAN_SCHEMA, PinState, Plan, Postcondition,
451        Precondition, Readiness, RecordState, Release, Repository, Requirement, ResolvedRelease,
452        Verdict, Verification,
453    };
454    use crate::digest::Digest;
455    use crate::landing::Kind;
456    use crate::plan::classify::Finding;
457    use crate::plan::evidence::{EvidenceItem, EvidenceKind};
458
459    /// The complete `rk.plan/1` shape, every section present, held by
460    /// snapshot: a field rename or removal fails here and becomes a
461    /// deliberate schema bump.
462    #[test]
463    #[allow(
464        clippy::too_many_lines,
465        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"
466    )]
467    fn the_plan_schema_is_versioned_and_snapshot_tested() {
468        let a = Digest::of(b"a");
469        let b = Digest::of(b"b");
470        let plan = Plan {
471            schema: PLAN_SCHEMA,
472            identity: Identity {
473                plan_id: "0123456789abcdef".into(),
474                created_at: "2026-01-01T00:00:00Z".into(),
475                engine_version: "0.0.0".into(),
476            },
477            classification: Classification::Upgrade,
478            findings: vec![Finding {
479                code: "payload-collision",
480                detail: "SECURITY.md".into(),
481            }],
482            desired_state: DesiredState {
483                selector: "embedded".into(),
484                release: ResolvedRelease {
485                    version: "0.0.0".into(),
486                    venue: "embedded".into(),
487                    payload_sha256: a.clone(),
488                    payload_schema: 1,
489                },
490                configuration: Some(Configuration {
491                    tech: "rust".into(),
492                    forge: "github".into(),
493                    repo: "acme/widget".into(),
494                    workflow: "worktree".into(),
495                    style: Some("trunk".into()),
496                    nix: false,
497                    trunk: "master".into(),
498                    line_prefix: "release/".into(),
499                    security_contact: String::new(),
500                    security_response: "best-effort".into(),
501                    sources: BTreeMap::from([("tech".to_owned(), "record".to_owned())]),
502                    evidence_refs: vec!["record".into()],
503                }),
504                unresolved: None,
505            },
506            observed_state: super::ObservedState {
507                repository: Repository {
508                    target: "/tmp/t".into(),
509                    git: true,
510                    tags: 0,
511                    long_lived_branches: vec![],
512                    release_markers: vec![],
513                    collisions: vec!["SECURITY.md".into()],
514                    tech: Some("rust".into()),
515                    forge: Some("github".into()),
516                    repo: Some("acme/widget".into()),
517                    verdict: Verdict::Brownfield,
518                    evidence_refs: vec!["repository".into()],
519                },
520                installation: Installation {
521                    record: RecordState::Present {
522                        rk_version: "0.0.0".into(),
523                        payload_sha256: a.clone(),
524                        schema_version: 6,
525                        origin: "init".into(),
526                        sha256: b.clone(),
527                    },
528                    configuration: ConfigurationState {
529                        present: true,
530                        sha256: Some(b.clone()),
531                        invalid: None,
532                        pending: vec![],
533                    },
534                    destinations: vec![Destination {
535                        path: "SECURITY.md".into(),
536                        present: true,
537                        sha256: Some(a.clone()),
538                        recorded_kind: Some(Kind::Rendered),
539                    }],
540                    evidence_refs: vec!["record".into(), "configuration".into()],
541                },
542                host: Host {
543                    engine_version: "0.0.0".into(),
544                    pin: Some(PinState {
545                        manager: "mise".into(),
546                        file: "mise.toml".into(),
547                        version: "0.0.0".into(),
548                    }),
549                    evidence_refs: vec!["host".into()],
550                },
551                forge: ForgeState::NotObserved {
552                    reason: "not requested".into(),
553                },
554            },
555            release: Release {
556                candidate: BundleIdentity {
557                    version: "0.0.0".into(),
558                    payload_sha256: a.clone(),
559                    payload_schema: 1,
560                    artifacts: 1,
561                    evidence_refs: vec!["candidate-bundle".into()],
562                },
563                verification: Verification::Embedded,
564                baseline: BaselineState::Embedded,
565                compatibility: Compatibility {
566                    engine_schema: 1,
567                    bundle_schema: 1,
568                    readable: true,
569                },
570                guidance: Guidance {
571                    coverage: "not-shipped",
572                },
573            },
574            operations: vec![Operation::WriteRecord {
575                before: Some(b.clone()),
576                after: a.clone(),
577            }],
578            preconditions: vec![Precondition {
579                id: "record-readable".into(),
580                requirement: Requirement::Required,
581                evaluation: Evaluation::Satisfied,
582                decision: None,
583                evidence_refs: vec!["record".into()],
584            }],
585            decisions: vec![Decision {
586                id: "workflow-mode".into(),
587                question: "which working-copy mode".into(),
588                choices: vec![Choice {
589                    answer: "worktree".into(),
590                    consequence: "every branch in a linked worktree".into(),
591                }],
592                selected: Some("worktree".into()),
593            }],
594            postconditions: vec![Postcondition::RecordReadsBack { sha256: a.clone() }],
595            evidence: vec![EvidenceItem {
596                id: "record".into(),
597                kind: EvidenceKind::Record,
598                producer: "rk".into(),
599                observed_at: "2026-01-01T00:00:00Z".into(),
600                sha256: Some(b.clone()),
601                method: "read".into(),
602            }],
603            readiness: Readiness::Ready,
604            input_fingerprint: a.clone(),
605        };
606        let json = serde_json::to_string(&plan).expect("a plan serializes");
607        let expected = format!(
608            r#"{{"schema":"rk.plan/1","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":{{"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}},"guidance":{{"coverage":"not-shipped"}}}},"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}"}}"#
609        );
610        assert_eq!(json, expected);
611    }
612}