1pub 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
32pub const PLAN_SCHEMA: &str = "rk.plan/1";
34
35#[derive(Debug, Serialize)]
37pub struct Plan {
38 pub schema: &'static str,
40 pub identity: Identity,
42 pub classification: Classification,
44 pub findings: Vec<Finding>,
46 pub desired_state: DesiredState,
48 pub observed_state: ObservedState,
50 pub release: Release,
52 pub operations: Vec<Operation>,
54 pub preconditions: Vec<Precondition>,
56 pub decisions: Vec<Decision>,
58 pub postconditions: Vec<Postcondition>,
60 pub evidence: Vec<EvidenceItem>,
62 pub readiness: Readiness,
64 pub input_fingerprint: Digest,
66}
67
68#[derive(Debug, Clone, Serialize)]
70pub struct Identity {
71 pub plan_id: String,
75 pub created_at: String,
77 pub engine_version: String,
79}
80
81#[derive(Debug, Clone, Serialize)]
83pub struct DesiredState {
84 pub selector: String,
87 pub release: ResolvedRelease,
89 #[serde(skip_serializing_if = "Option::is_none")]
92 pub configuration: Option<Configuration>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub unresolved: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize)]
100pub struct ResolvedRelease {
101 pub version: String,
103 pub venue: String,
105 pub payload_sha256: Digest,
107 pub payload_schema: u32,
109}
110
111#[derive(Debug, Clone, Serialize)]
113pub struct Configuration {
114 pub tech: String,
116 pub forge: String,
118 pub repo: String,
120 pub workflow: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub style: Option<String>,
125 pub nix: bool,
127 pub trunk: String,
129 pub line_prefix: String,
131 pub security_contact: String,
133 pub security_response: String,
135 pub sources: BTreeMap<String, String>,
138 pub evidence_refs: Vec<String>,
140}
141
142#[derive(Debug, Clone, Serialize)]
144pub struct ObservedState {
145 pub repository: Repository,
147 pub installation: Installation,
149 pub host: Host,
151 pub forge: ForgeState,
153}
154
155#[derive(Debug, Clone, Serialize)]
157pub struct Repository {
158 pub target: String,
160 pub git: bool,
162 pub tags: usize,
164 pub long_lived_branches: Vec<String>,
166 pub release_markers: Vec<String>,
168 pub collisions: Vec<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub tech: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub forge: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub repo: Option<String>,
179 pub verdict: Verdict,
181 pub evidence_refs: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize)]
187pub struct Installation {
188 pub record: RecordState,
190 pub configuration: ConfigurationState,
192 pub destinations: Vec<Destination>,
194 pub evidence_refs: Vec<String>,
196}
197
198#[derive(Debug, Clone, Serialize)]
200#[serde(tag = "state", rename_all = "kebab-case")]
201pub enum RecordState {
202 Absent,
204 Present {
206 rk_version: String,
208 payload_sha256: Digest,
210 schema_version: u64,
212 origin: String,
214 sha256: Digest,
216 },
217 Invalid {
219 reason: String,
221 },
222}
223
224#[derive(Debug, Clone, Serialize)]
226pub struct ConfigurationState {
227 pub present: bool,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub sha256: Option<Digest>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub invalid: Option<String>,
235 pub pending: Vec<String>,
237}
238
239#[derive(Debug, Clone, Serialize)]
241pub struct Destination {
242 pub path: String,
244 pub present: bool,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub sha256: Option<Digest>,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub recorded_kind: Option<Kind>,
252}
253
254#[derive(Debug, Clone, Serialize)]
256pub struct Host {
257 pub engine_version: String,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub pin: Option<PinState>,
262 pub evidence_refs: Vec<String>,
264}
265
266#[derive(Debug, Clone, Serialize)]
268pub struct PinState {
269 pub manager: String,
271 pub file: String,
273 pub version: String,
275}
276
277#[derive(Debug, Clone, Serialize)]
279#[serde(tag = "state", rename_all = "kebab-case")]
280pub enum ForgeState {
281 NotObserved {
283 reason: String,
285 },
286 Observed {
288 trunk: String,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 remote_tip: Option<String>,
293 evidence_refs: Vec<String>,
295 },
296}
297
298#[derive(Debug, Clone, Serialize)]
300pub struct Release {
301 pub candidate: BundleIdentity,
303 pub verification: Verification,
305 pub baseline: BaselineState,
307 pub compatibility: Compatibility,
309 pub guidance: Guidance,
311}
312
313#[derive(Debug, Clone, Serialize)]
315pub struct BundleIdentity {
316 pub version: String,
318 pub payload_sha256: Digest,
320 pub payload_schema: u32,
322 pub artifacts: usize,
324 pub evidence_refs: Vec<String>,
326}
327
328#[derive(Debug, Clone, Serialize)]
330#[serde(tag = "method", rename_all = "kebab-case")]
331pub enum Verification {
332 Embedded,
334 RegistryChecksum {
336 cksum: Digest,
338 },
339 Directory,
341}
342
343#[derive(Debug, Clone, Serialize)]
345#[serde(tag = "state", rename_all = "kebab-case")]
346pub enum BaselineState {
347 NotNeeded,
349 Embedded,
351 Cached {
353 version: String,
355 },
356 NotObserved {
359 reason: String,
361 },
362}
363
364#[derive(Debug, Clone, Serialize)]
366pub struct Compatibility {
367 pub engine_schema: u32,
369 pub bundle_schema: u32,
371 pub readable: bool,
373}
374
375#[derive(Debug, Clone, Serialize)]
377pub struct Guidance {
378 pub coverage: &'static str,
381}
382
383#[derive(Debug, Clone, Serialize)]
385pub struct Decision {
386 pub id: String,
388 pub question: String,
390 pub choices: Vec<Choice>,
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub selected: Option<String>,
395}
396
397#[derive(Debug, Clone, Serialize)]
399pub struct Choice {
400 pub answer: String,
402 pub consequence: String,
404}
405
406#[derive(Debug, Clone, Serialize)]
408#[serde(tag = "check", rename_all = "kebab-case")]
409pub enum Postcondition {
410 RecordReadsBack {
412 sha256: Digest,
414 },
415 DestinationHolds {
417 path: String,
419 sha256: Digest,
421 },
422 StatusCheckClean,
424 PinReads {
426 manager: String,
428 version: String,
430 },
431}
432
433#[derive(Debug)]
435pub struct Planned {
436 pub plan: Plan,
438 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 #[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}