1pub mod apply;
13pub mod classify;
14pub mod evidence;
15pub mod fingerprint;
16pub mod gather;
17pub mod operation;
18pub mod planner;
19pub mod readiness;
20pub mod store;
21
22use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25
26pub use classify::{Classification, Finding, Verdict};
27pub use evidence::{EvidenceItem, EvidenceKind};
28pub use operation::Operation;
29pub use readiness::{Evaluation, Precondition, Readiness, Requirement};
30
31use crate::digest::Digest;
32use crate::landing::Kind;
33
34pub const PLAN_SCHEMA: &str = "rk.plan/2";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum Intent {
42 Reconcile,
44 Setup,
46 Upgrade,
49 Adopt,
52}
53
54impl Intent {
55 #[must_use]
57 pub const fn as_str(self) -> &'static str {
58 match self {
59 Self::Reconcile => "reconcile",
60 Self::Setup => "setup",
61 Self::Upgrade => "upgrade",
62 Self::Adopt => "adopt",
63 }
64 }
65}
66
67#[derive(Debug, Serialize, Deserialize)]
69pub struct Plan {
70 pub schema: std::borrow::Cow<'static, str>,
72 pub identity: Identity,
74 pub classification: Classification,
76 pub findings: Vec<Finding>,
78 pub desired_state: DesiredState,
80 pub observed_state: ObservedState,
82 pub release: Release,
84 pub operations: Vec<Operation>,
86 pub preconditions: Vec<Precondition>,
88 pub decisions: Vec<Decision>,
90 pub postconditions: Vec<Postcondition>,
92 pub evidence: Vec<EvidenceItem>,
94 pub readiness: Readiness,
96 pub input_fingerprint: Digest,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Identity {
103 pub plan_id: String,
107 pub created_at: String,
109 pub engine_version: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DesiredState {
116 pub intent: Intent,
118 pub selector: String,
121 pub release: ResolvedRelease,
123 #[serde(skip_serializing_if = "Option::is_none")]
126 pub configuration: Option<Configuration>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub unresolved: Option<String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ResolvedRelease {
135 pub version: String,
137 pub venue: String,
139 pub payload_sha256: Digest,
141 pub payload_schema: u32,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct Configuration {
148 pub tech: String,
150 pub forge: String,
152 pub repo: String,
154 pub workflow: String,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub style: Option<String>,
159 pub nix: bool,
161 pub trunk: String,
163 pub line_prefix: String,
165 pub security_contact: String,
167 pub security_response: String,
169 pub sources: BTreeMap<String, String>,
172 pub evidence_refs: Vec<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ObservedState {
179 pub repository: Repository,
181 pub installation: Installation,
183 pub host: Host,
185 pub forge: ForgeState,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct Repository {
192 pub target: String,
194 pub git: bool,
196 pub tags: usize,
198 pub long_lived_branches: Vec<String>,
200 pub release_markers: Vec<String>,
202 pub collisions: Vec<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub tech: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub forge: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub repo: Option<String>,
213 pub verdict: Verdict,
215 pub evidence_refs: Vec<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct Installation {
222 pub record: RecordState,
224 pub configuration: ConfigurationState,
226 pub destinations: Vec<Destination>,
228 pub evidence_refs: Vec<String>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(tag = "state", rename_all = "kebab-case")]
235pub enum RecordState {
236 Absent,
238 Present {
240 rk_version: String,
242 payload_sha256: Digest,
244 schema_version: u64,
246 origin: String,
248 sha256: Digest,
250 },
251 Invalid {
253 reason: String,
255 },
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct ConfigurationState {
261 pub present: bool,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub sha256: Option<Digest>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub invalid: Option<String>,
269 pub pending: Vec<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct Destination {
276 pub path: String,
278 pub present: bool,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub sha256: Option<Digest>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub recorded_kind: Option<Kind>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct Host {
291 pub engine_version: String,
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub pin: Option<PinState>,
296 pub evidence_refs: Vec<String>,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct PinState {
303 pub manager: String,
305 pub file: String,
307 pub version: String,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(tag = "state", rename_all = "kebab-case")]
314pub enum ForgeState {
315 NotObserved {
317 reason: String,
319 },
320 Observed {
322 trunk: String,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 remote_tip: Option<String>,
327 evidence_refs: Vec<String>,
329 },
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct Release {
335 pub candidate: BundleIdentity,
337 pub verification: Verification,
339 pub baseline: BaselineState,
341 pub compatibility: Compatibility,
343 pub guidance: Guidance,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct BundleIdentity {
350 pub version: String,
352 pub payload_sha256: Digest,
354 pub payload_schema: u32,
356 pub artifacts: usize,
358 pub evidence_refs: Vec<String>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(tag = "method", rename_all = "kebab-case")]
365pub enum Verification {
366 Embedded,
368 RegistryChecksum {
370 cksum: Digest,
372 },
373 Directory,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(tag = "state", rename_all = "kebab-case")]
380pub enum BaselineState {
381 NotNeeded,
383 Embedded,
385 Cached {
387 version: String,
389 },
390 NotObserved {
393 reason: String,
395 },
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct Compatibility {
401 pub engine_schema: u32,
403 pub bundle_schema: u32,
405 pub readable: bool,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct Guidance {
412 pub coverage: std::borrow::Cow<'static, str>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct Decision {
420 pub id: String,
422 pub question: String,
424 pub choices: Vec<Choice>,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 pub selected: Option<String>,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct Choice {
434 pub answer: String,
436 pub consequence: String,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(tag = "check", rename_all = "kebab-case")]
443pub enum Postcondition {
444 RecordReadsBack {
446 sha256: Digest,
448 },
449 DestinationHolds {
451 path: String,
453 sha256: Digest,
455 },
456 StatusCheckClean,
458 PinReads {
460 manager: String,
462 version: String,
464 },
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct PlanRequest {
471 pub target: camino::Utf8PathBuf,
473 pub intent: Intent,
475 pub selector: String,
477 pub fetch: bool,
479 pub observe_forge: bool,
481 pub flags: gather::Flags,
483 pub decisions: BTreeMap<String, String>,
485}
486
487#[derive(Debug)]
491pub struct Planned {
492 pub plan: Plan,
494 pub blobs: BTreeMap<Digest, Vec<u8>>,
497 pub outcomes: Vec<DestinationOutcome>,
500 pub config: Option<crate::config::Plan>,
502 pub withheld: Vec<crate::landing::Withheld>,
504}
505
506#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct DestinationOutcome {
509 pub path: String,
511 pub kind: Kind,
513 pub recorded: bool,
515 pub disposition: Disposition,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub enum Disposition {
522 Write,
524 Unchanged,
527 Kept,
529 Drift,
531 State,
533 Conflict,
535 Missing,
537}
538
539impl Disposition {
540 #[must_use]
542 pub const fn as_str(self) -> &'static str {
543 match self {
544 Self::Write => "write",
545 Self::Unchanged => "unchanged",
546 Self::Kept => "kept",
547 Self::Drift => "drift",
548 Self::State => "state",
549 Self::Conflict => "conflict",
550 Self::Missing => "missing",
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use std::collections::BTreeMap;
558
559 use super::{
560 BaselineState, BundleIdentity, Choice, Classification, Compatibility, Configuration,
561 ConfigurationState, Decision, DesiredState, Destination, Evaluation, ForgeState, Guidance,
562 Host, Identity, Installation, Intent, Operation, PLAN_SCHEMA, PinState, Plan,
563 Postcondition, Precondition, Readiness, RecordState, Release, Repository, Requirement,
564 ResolvedRelease, Verdict, Verification,
565 };
566 use crate::digest::Digest;
567 use crate::landing::Kind;
568 use crate::plan::classify::Finding;
569 use crate::plan::evidence::{EvidenceItem, EvidenceKind};
570
571 #[test]
575 #[allow(
576 clippy::too_many_lines,
577 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"
578 )]
579 fn the_plan_schema_is_versioned_and_snapshot_tested() {
580 let a = Digest::of(b"a");
581 let b = Digest::of(b"b");
582 let plan = Plan {
583 schema: PLAN_SCHEMA.into(),
584 identity: Identity {
585 plan_id: "0123456789abcdef".into(),
586 created_at: "2026-01-01T00:00:00Z".into(),
587 engine_version: "0.0.0".into(),
588 },
589 classification: Classification::Upgrade,
590 findings: vec![Finding {
591 code: "payload-collision".into(),
592 detail: "SECURITY.md".into(),
593 }],
594 desired_state: DesiredState {
595 intent: Intent::Reconcile,
596 selector: "embedded".into(),
597 release: ResolvedRelease {
598 version: "0.0.0".into(),
599 venue: "embedded".into(),
600 payload_sha256: a.clone(),
601 payload_schema: 1,
602 },
603 configuration: Some(Configuration {
604 tech: "rust".into(),
605 forge: "github".into(),
606 repo: "acme/widget".into(),
607 workflow: "worktree".into(),
608 style: Some("trunk".into()),
609 nix: false,
610 trunk: "master".into(),
611 line_prefix: "release/".into(),
612 security_contact: String::new(),
613 security_response: "best-effort".into(),
614 sources: BTreeMap::from([("tech".to_owned(), "record".to_owned())]),
615 evidence_refs: vec!["record".into()],
616 }),
617 unresolved: None,
618 },
619 observed_state: super::ObservedState {
620 repository: Repository {
621 target: "/tmp/t".into(),
622 git: true,
623 tags: 0,
624 long_lived_branches: vec![],
625 release_markers: vec![],
626 collisions: vec!["SECURITY.md".into()],
627 tech: Some("rust".into()),
628 forge: Some("github".into()),
629 repo: Some("acme/widget".into()),
630 verdict: Verdict::Brownfield,
631 evidence_refs: vec!["repository".into()],
632 },
633 installation: Installation {
634 record: RecordState::Present {
635 rk_version: "0.0.0".into(),
636 payload_sha256: a.clone(),
637 schema_version: 6,
638 origin: "init".into(),
639 sha256: b.clone(),
640 },
641 configuration: ConfigurationState {
642 present: true,
643 sha256: Some(b.clone()),
644 invalid: None,
645 pending: vec![],
646 },
647 destinations: vec![Destination {
648 path: "SECURITY.md".into(),
649 present: true,
650 sha256: Some(a.clone()),
651 recorded_kind: Some(Kind::Rendered),
652 }],
653 evidence_refs: vec!["record".into(), "configuration".into()],
654 },
655 host: Host {
656 engine_version: "0.0.0".into(),
657 pin: Some(PinState {
658 manager: "mise".into(),
659 file: "mise.toml".into(),
660 version: "0.0.0".into(),
661 }),
662 evidence_refs: vec!["host".into()],
663 },
664 forge: ForgeState::NotObserved {
665 reason: "not requested".into(),
666 },
667 },
668 release: Release {
669 candidate: BundleIdentity {
670 version: "0.0.0".into(),
671 payload_sha256: a.clone(),
672 payload_schema: 1,
673 artifacts: 1,
674 evidence_refs: vec!["candidate-bundle".into()],
675 },
676 verification: Verification::Embedded,
677 baseline: BaselineState::Embedded,
678 compatibility: Compatibility {
679 engine_schema: 1,
680 bundle_schema: 1,
681 readable: true,
682 },
683 guidance: Guidance {
684 coverage: "not-shipped".into(),
685 },
686 },
687 operations: vec![Operation::WriteRecord {
688 before: Some(b.clone()),
689 after: a.clone(),
690 }],
691 preconditions: vec![Precondition {
692 id: "record-readable".into(),
693 requirement: Requirement::Required,
694 evaluation: Evaluation::Satisfied,
695 decision: None,
696 evidence_refs: vec!["record".into()],
697 }],
698 decisions: vec![Decision {
699 id: "workflow-mode".into(),
700 question: "which working-copy mode".into(),
701 choices: vec![Choice {
702 answer: "worktree".into(),
703 consequence: "every branch in a linked worktree".into(),
704 }],
705 selected: Some("worktree".into()),
706 }],
707 postconditions: vec![Postcondition::RecordReadsBack { sha256: a.clone() }],
708 evidence: vec![EvidenceItem {
709 id: "record".into(),
710 kind: EvidenceKind::Record,
711 producer: "rk".into(),
712 observed_at: "2026-01-01T00:00:00Z".into(),
713 sha256: Some(b.clone()),
714 method: "read".into(),
715 }],
716 readiness: Readiness::Ready,
717 input_fingerprint: a.clone(),
718 };
719 let json = serde_json::to_string(&plan).expect("a plan serializes");
720 let expected = format!(
721 r#"{{"schema":"rk.plan/2","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}},"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}"}}"#
722 );
723 assert_eq!(json, expected);
724 }
725}