release-kit 0.3.24

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
//! The plan: one typed, immutable document that is the input to every
//! landing write.
//!
//! The document keeps five kinds apart, and an addition inside one kind
//! is additive. Evidence is what was observed. Analysis is what the
//! engine derived from it: the operations, the compatibility, the
//! guidance. Policy is the requirement each precondition carries.
//! Decisions are workflow state the operator owns. Postconditions are
//! what proves completion. `rk reconcile plan` computes one and prints
//! it; nothing here writes into a target.

pub mod classify;
pub mod evidence;
pub mod fingerprint;
pub mod gather;
pub mod operation;
pub mod planner;
pub mod readiness;

use std::collections::BTreeMap;

use serde::Serialize;

pub use classify::{Classification, Finding, Verdict};
pub use evidence::{EvidenceItem, EvidenceKind};
pub use operation::Operation;
pub use readiness::{Evaluation, Precondition, Readiness, Requirement};

use crate::digest::Digest;
use crate::landing::Kind;

/// The version of the plan's shape.
pub const PLAN_SCHEMA: &str = "rk.plan/1";

/// The plan, whole.
#[derive(Debug, Serialize)]
pub struct Plan {
    /// The shape version of this document.
    pub schema: &'static str,
    /// Who computed it, when, under which id.
    pub identity: Identity,
    /// Which procedure this plan is.
    pub classification: Classification,
    /// What the classification compresses.
    pub findings: Vec<Finding>,
    /// What the target is asked to converge toward.
    pub desired_state: DesiredState,
    /// What the target was found to be.
    pub observed_state: ObservedState,
    /// The candidate bundle and what is known about it.
    pub release: Release,
    /// The typed changes, in apply order.
    pub operations: Vec<Operation>,
    /// Each with its requirement and its evaluation.
    pub preconditions: Vec<Precondition>,
    /// The questions the operator owns, with their selected answers.
    pub decisions: Vec<Decision>,
    /// The typed checks an apply runs at the end and reports.
    pub postconditions: Vec<Postcondition>,
    /// Every observed value, cited by the fields above.
    pub evidence: Vec<EvidenceItem>,
    /// Whether the plan may be applied.
    pub readiness: Readiness,
    /// One canonical digest over the semantic inputs.
    pub input_fingerprint: Digest,
}

/// Who computed the plan, when, and under which id.
#[derive(Debug, Clone, Serialize)]
pub struct Identity {
    /// Derived from the fingerprint and the creation instant, so two
    /// plans over the same inputs are distinguishable and one plan is
    /// not stored twice by accident.
    pub plan_id: String,
    /// The instant the plan was computed, RFC 3339.
    pub created_at: String,
    /// The engine that computed it.
    pub engine_version: String,
}

/// What the target is asked to converge toward.
#[derive(Debug, Clone, Serialize)]
pub struct DesiredState {
    /// The selector as the operator gave it: `embedded`, `latest`, or an
    /// exact version.
    pub selector: String,
    /// What the selector resolved to, once, frozen here.
    pub release: ResolvedRelease,
    /// The landing configuration the projection renders under, or the
    /// reason none resolved.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub configuration: Option<Configuration>,
    /// Why the configuration did not resolve, where it did not.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub unresolved: Option<String>,
}

/// One exact release, resolved at plan time and never again.
#[derive(Debug, Clone, Serialize)]
pub struct ResolvedRelease {
    /// The exact version.
    pub version: String,
    /// Where it was read from: `embedded`, `crates`, or `directory`.
    pub venue: String,
    /// The bundle's aggregate digest.
    pub payload_sha256: Digest,
    /// The bundle's protocol version.
    pub payload_schema: u32,
}

/// The landing parameters, resolved, with the layer each one came from.
#[derive(Debug, Clone, Serialize)]
pub struct Configuration {
    /// The payload binding.
    pub tech: String,
    /// The forge.
    pub forge: String,
    /// The project path on the forge.
    pub repo: String,
    /// The working-copy mode.
    pub workflow: String,
    /// The release style, where one is answered.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub style: Option<String>,
    /// Whether the landing carries the Nix capability.
    pub nix: bool,
    /// The one permanent branch.
    pub trunk: String,
    /// The release-line prefix.
    pub line_prefix: String,
    /// The security contact the policy names, empty for the forge's own.
    pub security_contact: String,
    /// The acknowledgment window the policy promises.
    pub security_response: String,
    /// Which layer answered each parameter: `flag`, `configuration`,
    /// `record`, `detected`, or `default`.
    pub sources: BTreeMap<String, String>,
    /// The evidence the resolution read.
    pub evidence_refs: Vec<String>,
}

/// What the target was found to be.
#[derive(Debug, Clone, Serialize)]
pub struct ObservedState {
    /// The repository's own state.
    pub repository: Repository,
    /// What release-kit landed there, as far as the disk says.
    pub installation: Installation,
    /// The engine and the host.
    pub host: Host,
    /// What the forge said, where it was asked.
    pub forge: ForgeState,
}

/// The repository's own state, read off the disk and git.
#[derive(Debug, Clone, Serialize)]
pub struct Repository {
    /// The target directory.
    pub target: String,
    /// Whether the target is a git repository.
    pub git: bool,
    /// How many tags it holds.
    pub tags: usize,
    /// Long-lived branches beside the trunk.
    pub long_lived_branches: Vec<String>,
    /// Other tools' release markers present.
    pub release_markers: Vec<String>,
    /// Payload destinations already present.
    pub collisions: Vec<String>,
    /// The technology the version file names, where one is found.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tech: Option<String>,
    /// The forge the origin remote maps to, where one is recognized.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub forge: Option<String>,
    /// The project path from the origin remote, where one exists.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// The corpus verdict the facts above earn.
    pub verdict: Verdict,
    /// The evidence these facts rest on.
    pub evidence_refs: Vec<String>,
}

/// What release-kit landed at the target.
#[derive(Debug, Clone, Serialize)]
pub struct Installation {
    /// The landing record.
    pub record: RecordState,
    /// The committed configuration.
    pub configuration: ConfigurationState,
    /// Every destination the candidate or the record names, as found.
    pub destinations: Vec<Destination>,
    /// The evidence the installation rests on.
    pub evidence_refs: Vec<String>,
}

/// The landing record, as found.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum RecordState {
    /// No record at the target.
    Absent,
    /// A record this engine read.
    Present {
        /// The binary that wrote it.
        rk_version: String,
        /// The payload that landed.
        payload_sha256: Digest,
        /// The record's schema.
        schema_version: u64,
        /// How the record came to exist.
        origin: String,
        /// The digest of the record's bytes.
        sha256: Digest,
    },
    /// A record this engine could not read.
    Invalid {
        /// Why.
        reason: String,
    },
}

/// The committed configuration, as found.
#[derive(Debug, Clone, Serialize)]
pub struct ConfigurationState {
    /// Whether `.release-kit/config.toml` exists.
    pub present: bool,
    /// The digest of its bytes, where present.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sha256: Option<Digest>,
    /// Why it did not read, where it did not.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub invalid: Option<String>,
    /// Keys whose configured answers the record has yet to take up.
    pub pending: Vec<String>,
}

/// One destination, as found.
#[derive(Debug, Clone, Serialize)]
pub struct Destination {
    /// The destination, relative to the target.
    pub path: String,
    /// Whether the file, or the marked block, is present.
    pub present: bool,
    /// The digest of what is there, where present.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sha256: Option<Digest>,
    /// The kind the record declares for it, where the record names it.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recorded_kind: Option<Kind>,
}

/// The engine and the host.
#[derive(Debug, Clone, Serialize)]
pub struct Host {
    /// This engine's version.
    pub engine_version: String,
    /// The pin the wired manager records for `rk`, where one does.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pin: Option<PinState>,
    /// The evidence the host facts rest on.
    pub evidence_refs: Vec<String>,
}

/// The `rk` pin a tool manager records.
#[derive(Debug, Clone, Serialize)]
pub struct PinState {
    /// The manager.
    pub manager: String,
    /// The file that records it.
    pub file: String,
    /// The version, as the manager records it.
    pub version: String,
}

/// What the forge said, where it was asked.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum ForgeState {
    /// The forge was not asked, and the reason says why.
    NotObserved {
        /// Why.
        reason: String,
    },
    /// The forge was asked.
    Observed {
        /// The trunk the read asked about.
        trunk: String,
        /// The trunk's tip at the remote, where it has one.
        #[serde(skip_serializing_if = "Option::is_none")]
        remote_tip: Option<String>,
        /// The evidence the read produced.
        evidence_refs: Vec<String>,
    },
}

/// The candidate bundle and what is known about it.
#[derive(Debug, Clone, Serialize)]
pub struct Release {
    /// The candidate's identity.
    pub candidate: BundleIdentity,
    /// How the candidate was verified.
    pub verification: Verification,
    /// The recorded release's bundle, for the three-way comparison.
    pub baseline: BaselineState,
    /// What the engine can say about reading this bundle.
    pub compatibility: Compatibility,
    /// The guidance the bundle carries for this target.
    pub guidance: Guidance,
}

/// One bundle's identity.
#[derive(Debug, Clone, Serialize)]
pub struct BundleIdentity {
    /// The release's version.
    pub version: String,
    /// The aggregate digest.
    pub payload_sha256: Digest,
    /// The protocol version.
    pub payload_schema: u32,
    /// How many artifacts the bundle carries.
    pub artifacts: usize,
    /// The evidence the identity rests on.
    pub evidence_refs: Vec<String>,
}

/// How a bundle was verified.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "method", rename_all = "kebab-case")]
pub enum Verification {
    /// The bundle is the one compiled into this engine.
    Embedded,
    /// The archive digested to the registry's checksum.
    RegistryChecksum {
        /// The checksum the index named.
        cksum: Digest,
    },
    /// A directory laid out as a bundle, read as is.
    Directory,
}

/// The recorded release's bundle, as read for the baseline.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum BaselineState {
    /// No record, so no baseline is needed.
    NotNeeded,
    /// The recorded payload is the one compiled into this engine.
    Embedded,
    /// The recorded release's bundle was read from the release cache.
    Cached {
        /// The recorded version.
        version: String,
    },
    /// The recorded release's bundle could not be read, and the reason
    /// says why.
    NotObserved {
        /// Why.
        reason: String,
    },
}

/// What the engine can say about reading this bundle.
#[derive(Debug, Clone, Serialize)]
pub struct Compatibility {
    /// The engine's protocol version.
    pub engine_schema: u32,
    /// The bundle's protocol version.
    pub bundle_schema: u32,
    /// Whether the engine reads the bundle.
    pub readable: bool,
}

/// The guidance the bundle carries for this target.
#[derive(Debug, Clone, Serialize)]
pub struct Guidance {
    /// `not-shipped` until a bundle carries guidance; the coverage words
    /// grow when one does.
    pub coverage: &'static str,
}

/// One question the operator owns.
#[derive(Debug, Clone, Serialize)]
pub struct Decision {
    /// A stable id that survives re-planning.
    pub id: String,
    /// The question, one line.
    pub question: String,
    /// The answers, each with its consequence.
    pub choices: Vec<Choice>,
    /// The answer selected, where one is.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selected: Option<String>,
}

/// One answer to a decision.
#[derive(Debug, Clone, Serialize)]
pub struct Choice {
    /// The answer word.
    pub answer: String,
    /// What selecting it means.
    pub consequence: String,
}

/// One typed check an apply runs at the end and reports.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "check", rename_all = "kebab-case")]
pub enum Postcondition {
    /// The record reads back at the planned digest.
    RecordReadsBack {
        /// The planned digest.
        sha256: Digest,
    },
    /// A destination holds the planned bytes.
    DestinationHolds {
        /// The destination.
        path: String,
        /// The planned digest.
        sha256: Digest,
    },
    /// `rk status --check` exits 0.
    StatusCheckClean,
    /// The wired manager records the planned version.
    PinReads {
        /// The manager.
        manager: String,
        /// The planned version.
        version: String,
    },
}

/// A computed plan with the bytes its operations name.
#[derive(Debug)]
pub struct Planned {
    /// The plan.
    pub plan: Plan,
    /// Every byte the plan names, by digest: what an operation writes,
    /// what a destination holds now, and the baseline where it was read.
    pub blobs: BTreeMap<Digest, Vec<u8>>,
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use super::{
        BaselineState, BundleIdentity, Choice, Classification, Compatibility, Configuration,
        ConfigurationState, Decision, DesiredState, Destination, Evaluation, ForgeState, Guidance,
        Host, Identity, Installation, Operation, PLAN_SCHEMA, PinState, Plan, Postcondition,
        Precondition, Readiness, RecordState, Release, Repository, Requirement, ResolvedRelease,
        Verdict, Verification,
    };
    use crate::digest::Digest;
    use crate::landing::Kind;
    use crate::plan::classify::Finding;
    use crate::plan::evidence::{EvidenceItem, EvidenceKind};

    /// The complete `rk.plan/1` shape, every section present, held by
    /// snapshot: a field rename or removal fails here and becomes a
    /// deliberate schema bump.
    #[test]
    #[allow(
        clippy::too_many_lines,
        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"
    )]
    fn the_plan_schema_is_versioned_and_snapshot_tested() {
        let a = Digest::of(b"a");
        let b = Digest::of(b"b");
        let plan = Plan {
            schema: PLAN_SCHEMA,
            identity: Identity {
                plan_id: "0123456789abcdef".into(),
                created_at: "2026-01-01T00:00:00Z".into(),
                engine_version: "0.0.0".into(),
            },
            classification: Classification::Upgrade,
            findings: vec![Finding {
                code: "payload-collision",
                detail: "SECURITY.md".into(),
            }],
            desired_state: DesiredState {
                selector: "embedded".into(),
                release: ResolvedRelease {
                    version: "0.0.0".into(),
                    venue: "embedded".into(),
                    payload_sha256: a.clone(),
                    payload_schema: 1,
                },
                configuration: Some(Configuration {
                    tech: "rust".into(),
                    forge: "github".into(),
                    repo: "acme/widget".into(),
                    workflow: "worktree".into(),
                    style: Some("trunk".into()),
                    nix: false,
                    trunk: "master".into(),
                    line_prefix: "release/".into(),
                    security_contact: String::new(),
                    security_response: "best-effort".into(),
                    sources: BTreeMap::from([("tech".to_owned(), "record".to_owned())]),
                    evidence_refs: vec!["record".into()],
                }),
                unresolved: None,
            },
            observed_state: super::ObservedState {
                repository: Repository {
                    target: "/tmp/t".into(),
                    git: true,
                    tags: 0,
                    long_lived_branches: vec![],
                    release_markers: vec![],
                    collisions: vec!["SECURITY.md".into()],
                    tech: Some("rust".into()),
                    forge: Some("github".into()),
                    repo: Some("acme/widget".into()),
                    verdict: Verdict::Brownfield,
                    evidence_refs: vec!["repository".into()],
                },
                installation: Installation {
                    record: RecordState::Present {
                        rk_version: "0.0.0".into(),
                        payload_sha256: a.clone(),
                        schema_version: 6,
                        origin: "init".into(),
                        sha256: b.clone(),
                    },
                    configuration: ConfigurationState {
                        present: true,
                        sha256: Some(b.clone()),
                        invalid: None,
                        pending: vec![],
                    },
                    destinations: vec![Destination {
                        path: "SECURITY.md".into(),
                        present: true,
                        sha256: Some(a.clone()),
                        recorded_kind: Some(Kind::Rendered),
                    }],
                    evidence_refs: vec!["record".into(), "configuration".into()],
                },
                host: Host {
                    engine_version: "0.0.0".into(),
                    pin: Some(PinState {
                        manager: "mise".into(),
                        file: "mise.toml".into(),
                        version: "0.0.0".into(),
                    }),
                    evidence_refs: vec!["host".into()],
                },
                forge: ForgeState::NotObserved {
                    reason: "not requested".into(),
                },
            },
            release: Release {
                candidate: BundleIdentity {
                    version: "0.0.0".into(),
                    payload_sha256: a.clone(),
                    payload_schema: 1,
                    artifacts: 1,
                    evidence_refs: vec!["candidate-bundle".into()],
                },
                verification: Verification::Embedded,
                baseline: BaselineState::Embedded,
                compatibility: Compatibility {
                    engine_schema: 1,
                    bundle_schema: 1,
                    readable: true,
                },
                guidance: Guidance {
                    coverage: "not-shipped",
                },
            },
            operations: vec![Operation::WriteRecord {
                before: Some(b.clone()),
                after: a.clone(),
            }],
            preconditions: vec![Precondition {
                id: "record-readable".into(),
                requirement: Requirement::Required,
                evaluation: Evaluation::Satisfied,
                decision: None,
                evidence_refs: vec!["record".into()],
            }],
            decisions: vec![Decision {
                id: "workflow-mode".into(),
                question: "which working-copy mode".into(),
                choices: vec![Choice {
                    answer: "worktree".into(),
                    consequence: "every branch in a linked worktree".into(),
                }],
                selected: Some("worktree".into()),
            }],
            postconditions: vec![Postcondition::RecordReadsBack { sha256: a.clone() }],
            evidence: vec![EvidenceItem {
                id: "record".into(),
                kind: EvidenceKind::Record,
                producer: "rk".into(),
                observed_at: "2026-01-01T00:00:00Z".into(),
                sha256: Some(b.clone()),
                method: "read".into(),
            }],
            readiness: Readiness::Ready,
            input_fingerprint: a.clone(),
        };
        let json = serde_json::to_string(&plan).expect("a plan serializes");
        let expected = format!(
            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}"}}"#
        );
        assert_eq!(json, expected);
    }
}