1pub mod apply;
13pub mod classify;
14pub mod compatibility;
15pub mod evidence;
16pub mod fingerprint;
17pub mod gather;
18pub mod guidance;
19pub mod operation;
20pub mod planner;
21pub mod readiness;
22pub mod store;
23
24use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27
28pub use classify::{Classification, Finding, Verdict};
29pub use evidence::{EvidenceItem, EvidenceKind};
30pub use operation::Operation;
31pub use readiness::{Evaluation, Precondition, Readiness, Requirement};
32
33use crate::digest::Digest;
34use crate::landing::Kind;
35
36pub const PLAN_SCHEMA: &str = "rk.plan/3";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum Intent {
44 Reconcile,
46 Setup,
48 Upgrade,
51 Adopt,
54}
55
56impl Intent {
57 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::Reconcile => "reconcile",
62 Self::Setup => "setup",
63 Self::Upgrade => "upgrade",
64 Self::Adopt => "adopt",
65 }
66 }
67}
68
69#[derive(Debug, Serialize, Deserialize)]
71pub struct Plan {
72 pub schema: std::borrow::Cow<'static, str>,
74 pub identity: Identity,
76 pub classification: Classification,
78 pub findings: Vec<Finding>,
80 pub desired_state: DesiredState,
82 pub observed_state: ObservedState,
84 pub release: Release,
86 pub operations: Vec<Operation>,
88 pub preconditions: Vec<Precondition>,
90 pub decisions: Vec<Decision>,
92 pub postconditions: Vec<Postcondition>,
94 pub evidence: Vec<EvidenceItem>,
96 pub readiness: Readiness,
98 pub input_fingerprint: Digest,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Identity {
105 pub plan_id: String,
109 pub created_at: String,
111 pub engine_version: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct DesiredState {
118 pub intent: Intent,
120 pub selector: String,
123 pub release: ResolvedRelease,
125 #[serde(skip_serializing_if = "Option::is_none")]
128 pub configuration: Option<Configuration>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub unresolved: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ResolvedRelease {
137 pub version: String,
139 pub venue: String,
141 pub payload_sha256: Digest,
143 pub payload_schema: u32,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Configuration {
150 pub tech: String,
152 pub forge: String,
154 pub repo: String,
156 pub workflow: String,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub style: Option<String>,
161 pub nix: bool,
163 pub trunk: String,
165 pub line_prefix: String,
167 pub security_contact: String,
169 pub security_response: String,
171 pub sources: BTreeMap<String, String>,
174 pub evidence_refs: Vec<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ObservedState {
181 pub repository: Repository,
183 pub installation: Installation,
185 pub host: Host,
187 pub forge: ForgeState,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct Repository {
194 pub target: String,
196 pub git: bool,
198 pub tags: usize,
200 pub long_lived_branches: Vec<String>,
202 pub release_markers: Vec<String>,
204 pub collisions: Vec<String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub tech: Option<String>,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub forge: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub repo: Option<String>,
215 pub verdict: Verdict,
217 pub evidence_refs: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Installation {
224 pub record: RecordState,
226 pub configuration: ConfigurationState,
228 pub destinations: Vec<Destination>,
230 pub evidence_refs: Vec<String>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(tag = "state", rename_all = "kebab-case")]
237pub enum RecordState {
238 Absent,
240 Present {
242 rk_version: String,
244 payload_sha256: Digest,
246 schema_version: u64,
248 origin: String,
250 sha256: Digest,
252 },
253 Invalid {
255 reason: String,
257 },
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ConfigurationState {
263 pub present: bool,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub sha256: Option<Digest>,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub invalid: Option<String>,
271 pub pending: Vec<String>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct Destination {
278 pub path: String,
280 pub present: bool,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub sha256: Option<Digest>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub recorded_kind: Option<Kind>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct Host {
293 pub engine_version: String,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub pin: Option<PinState>,
298 pub evidence_refs: Vec<String>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct PinState {
305 pub manager: String,
307 pub file: String,
309 pub version: String,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(tag = "state", rename_all = "kebab-case")]
316pub enum ForgeState {
317 NotObserved {
319 reason: String,
321 },
322 Observed {
324 trunk: String,
326 #[serde(skip_serializing_if = "Option::is_none")]
328 remote_tip: Option<String>,
329 evidence_refs: Vec<String>,
331 },
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct Release {
337 pub candidate: BundleIdentity,
339 pub verification: Verification,
341 pub baseline: BaselineState,
343 pub compatibility: Compatibility,
345 pub guidance: Guidance,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct BundleIdentity {
352 pub version: String,
354 pub payload_sha256: Digest,
356 pub payload_schema: u32,
358 pub artifacts: usize,
360 pub evidence_refs: Vec<String>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(tag = "method", rename_all = "kebab-case")]
367pub enum Verification {
368 Embedded,
370 RegistryChecksum {
372 cksum: Digest,
374 },
375 Directory,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(tag = "state", rename_all = "kebab-case")]
382pub enum BaselineState {
383 NotNeeded,
385 Embedded,
387 Cached {
389 version: String,
391 },
392 NotObserved {
395 reason: String,
397 },
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct Compatibility {
407 pub engine_schema: u32,
409 pub bundle_schema: u32,
411 pub readable: bool,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub engine_minimum: Option<String>,
416 #[serde(skip_serializing_if = "Option::is_none")]
419 pub generator: Option<GeneratorFact>,
420 #[serde(skip_serializing_if = "Option::is_none")]
423 pub forge_floor: Option<ForgeFloor>,
424 pub intermediate: Vec<IntermediateFact>,
427 pub evidence_refs: Vec<String>,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct GeneratorFact {
434 pub name: String,
436 pub pin: String,
438 pub artifact: String,
440 #[serde(skip_serializing_if = "Option::is_none")]
442 pub host: Option<String>,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct ForgeFloor {
448 pub forge: String,
450 pub minimum: String,
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub observed: Option<String>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct IntermediateFact {
460 pub version: String,
462 pub reason: String,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct Guidance {
469 pub coverage: Coverage,
471 #[serde(skip_serializing_if = "Option::is_none")]
473 pub interval: Option<Interval>,
474 pub steps: Vec<GuidanceStep>,
477 pub excluded: usize,
479 pub evidence_refs: Vec<String>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(tag = "state", rename_all = "kebab-case")]
487pub enum Coverage {
488 NotNeeded,
490 Covered,
492 Partial {
494 since: String,
496 },
497 Unavailable,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct Interval {
505 pub from: String,
507 pub to: String,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct GuidanceStep {
514 pub version: String,
516 pub title: String,
518 pub destinations: Vec<String>,
520 pub action: String,
522 pub body: String,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct Decision {
529 pub id: String,
531 pub question: String,
533 pub choices: Vec<Choice>,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub selected: Option<String>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
542pub struct Choice {
543 pub answer: String,
545 pub consequence: String,
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize)]
551#[serde(tag = "check", rename_all = "kebab-case")]
552pub enum Postcondition {
553 RecordReadsBack {
555 sha256: Digest,
557 },
558 DestinationHolds {
560 path: String,
562 sha256: Digest,
564 },
565 StatusCheckClean,
567 PinReads {
569 manager: String,
571 version: String,
573 },
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct PlanRequest {
580 pub target: camino::Utf8PathBuf,
582 pub intent: Intent,
584 pub selector: String,
586 pub fetch: bool,
588 pub observe_forge: bool,
590 pub flags: gather::Flags,
592 pub decisions: BTreeMap<String, String>,
594}
595
596#[derive(Debug)]
600pub struct Planned {
601 pub plan: Plan,
603 pub blobs: BTreeMap<Digest, Vec<u8>>,
606 pub outcomes: Vec<DestinationOutcome>,
609 pub config: Option<crate::config::Plan>,
611 pub withheld: Vec<crate::landing::Withheld>,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct DestinationOutcome {
618 pub path: String,
620 pub kind: Kind,
622 pub recorded: bool,
624 pub disposition: Disposition,
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum Disposition {
631 Write,
633 Unchanged,
636 Kept,
638 Drift,
640 State,
642 Conflict,
644 Missing,
646}
647
648impl Disposition {
649 #[must_use]
651 pub const fn as_str(self) -> &'static str {
652 match self {
653 Self::Write => "write",
654 Self::Unchanged => "unchanged",
655 Self::Kept => "kept",
656 Self::Drift => "drift",
657 Self::State => "state",
658 Self::Conflict => "conflict",
659 Self::Missing => "missing",
660 }
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use std::collections::BTreeMap;
667
668 use super::{
669 BaselineState, BundleIdentity, Choice, Classification, Compatibility, Configuration,
670 ConfigurationState, Coverage, Decision, DesiredState, Destination, Evaluation, ForgeFloor,
671 ForgeState, GeneratorFact, Guidance, GuidanceStep, Host, Identity, Installation, Intent,
672 IntermediateFact, Interval, Operation, PLAN_SCHEMA, PinState, Plan, Postcondition,
673 Precondition, Readiness, RecordState, Release, Repository, Requirement, ResolvedRelease,
674 Verdict, Verification,
675 };
676 use crate::digest::Digest;
677 use crate::landing::Kind;
678 use crate::plan::classify::Finding;
679 use crate::plan::evidence::{EvidenceItem, EvidenceKind};
680
681 #[test]
685 #[allow(
686 clippy::too_many_lines,
687 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"
688 )]
689 fn the_plan_schema_is_versioned_and_snapshot_tested() {
690 let a = Digest::of(b"a");
691 let b = Digest::of(b"b");
692 let plan = Plan {
693 schema: PLAN_SCHEMA.into(),
694 identity: Identity {
695 plan_id: "0123456789abcdef".into(),
696 created_at: "2026-01-01T00:00:00Z".into(),
697 engine_version: "0.0.0".into(),
698 },
699 classification: Classification::Upgrade,
700 findings: vec![Finding {
701 code: "payload-collision".into(),
702 detail: "SECURITY.md".into(),
703 }],
704 desired_state: DesiredState {
705 intent: Intent::Reconcile,
706 selector: "embedded".into(),
707 release: ResolvedRelease {
708 version: "0.0.0".into(),
709 venue: "embedded".into(),
710 payload_sha256: a.clone(),
711 payload_schema: 1,
712 },
713 configuration: Some(Configuration {
714 tech: "rust".into(),
715 forge: "github".into(),
716 repo: "acme/widget".into(),
717 workflow: "worktree".into(),
718 style: Some("trunk".into()),
719 nix: false,
720 trunk: "master".into(),
721 line_prefix: "release/".into(),
722 security_contact: String::new(),
723 security_response: "best-effort".into(),
724 sources: BTreeMap::from([("tech".to_owned(), "record".to_owned())]),
725 evidence_refs: vec!["record".into()],
726 }),
727 unresolved: None,
728 },
729 observed_state: super::ObservedState {
730 repository: Repository {
731 target: "/tmp/t".into(),
732 git: true,
733 tags: 0,
734 long_lived_branches: vec![],
735 release_markers: vec![],
736 collisions: vec!["SECURITY.md".into()],
737 tech: Some("rust".into()),
738 forge: Some("github".into()),
739 repo: Some("acme/widget".into()),
740 verdict: Verdict::Brownfield,
741 evidence_refs: vec!["repository".into()],
742 },
743 installation: Installation {
744 record: RecordState::Present {
745 rk_version: "0.0.0".into(),
746 payload_sha256: a.clone(),
747 schema_version: 6,
748 origin: "init".into(),
749 sha256: b.clone(),
750 },
751 configuration: ConfigurationState {
752 present: true,
753 sha256: Some(b.clone()),
754 invalid: None,
755 pending: vec![],
756 },
757 destinations: vec![Destination {
758 path: "SECURITY.md".into(),
759 present: true,
760 sha256: Some(a.clone()),
761 recorded_kind: Some(Kind::Rendered),
762 }],
763 evidence_refs: vec!["record".into(), "configuration".into()],
764 },
765 host: Host {
766 engine_version: "0.0.0".into(),
767 pin: Some(PinState {
768 manager: "mise".into(),
769 file: "mise.toml".into(),
770 version: "0.0.0".into(),
771 }),
772 evidence_refs: vec!["host".into()],
773 },
774 forge: ForgeState::NotObserved {
775 reason: "not requested".into(),
776 },
777 },
778 release: Release {
779 candidate: BundleIdentity {
780 version: "0.0.0".into(),
781 payload_sha256: a.clone(),
782 payload_schema: 1,
783 artifacts: 1,
784 evidence_refs: vec!["candidate-bundle".into()],
785 },
786 verification: Verification::Embedded,
787 baseline: BaselineState::Embedded,
788 compatibility: Compatibility {
789 engine_schema: 1,
790 bundle_schema: 1,
791 readable: true,
792 engine_minimum: Some("0.0.0".into()),
793 generator: Some(GeneratorFact {
794 name: "cargo-dist".into(),
795 pin: "0.32.0".into(),
796 artifact: "dist-workspace.toml".into(),
797 host: Some("0.32.0".into()),
798 }),
799 forge_floor: Some(ForgeFloor {
800 forge: "gitlab".into(),
801 minimum: "18.2".into(),
802 observed: Some("18.2.0".into()),
803 }),
804 intermediate: vec![IntermediateFact {
805 version: "0.0.0".into(),
806 reason: "the record changed shape".into(),
807 }],
808 evidence_refs: vec!["candidate-bundle".into()],
809 },
810 guidance: Guidance {
811 coverage: Coverage::Partial {
812 since: "0.0.0".into(),
813 },
814 interval: Some(Interval {
815 from: "0.0.0".into(),
816 to: "0.0.0".into(),
817 }),
818 steps: vec![GuidanceStep {
819 version: "0.0.0".into(),
820 title: "release-kit 0.0.0".into(),
821 destinations: vec![".envrc".into()],
822 action: "operator-step".into(),
823 body: "## What to do".into(),
824 }],
825 excluded: 1,
826 evidence_refs: vec!["candidate-bundle".into()],
827 },
828 },
829 operations: vec![Operation::WriteRecord {
830 before: Some(b.clone()),
831 after: a.clone(),
832 }],
833 preconditions: vec![Precondition {
834 id: "record-readable".into(),
835 requirement: Requirement::Required,
836 evaluation: Evaluation::Satisfied,
837 decision: None,
838 evidence_refs: vec!["record".into()],
839 }],
840 decisions: vec![Decision {
841 id: "workflow-mode".into(),
842 question: "which working-copy mode".into(),
843 choices: vec![Choice {
844 answer: "worktree".into(),
845 consequence: "every branch in a linked worktree".into(),
846 }],
847 selected: Some("worktree".into()),
848 }],
849 postconditions: vec![Postcondition::RecordReadsBack { sha256: a.clone() }],
850 evidence: vec![EvidenceItem {
851 id: "record".into(),
852 kind: EvidenceKind::Record,
853 producer: "rk".into(),
854 observed_at: "2026-01-01T00:00:00Z".into(),
855 sha256: Some(b.clone()),
856 method: "read".into(),
857 }],
858 readiness: Readiness::Ready,
859 input_fingerprint: a.clone(),
860 };
861 let json = serde_json::to_string(&plan).expect("a plan serializes");
862 let expected = format!(
863 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}"}}"###
864 );
865 assert_eq!(json, expected);
866 }
867}