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 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
36/// The version of the plan's shape.
37pub const PLAN_SCHEMA: &str = "rk.plan/3";
38
39/// What the caller asked the plan to be: the open reconciliation, or one
40/// of the three fronts, each of which fixes what the plan may contain.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum Intent {
44    /// `rk reconcile plan`: the classification decides.
45    Reconcile,
46    /// `rk init`: a first landing, refused over a record.
47    Setup,
48    /// `rk upgrade`: a recorded target takes the candidate, refused
49    /// without a record.
50    Upgrade,
51    /// `rk adopt`: the record and the configuration alone, every
52    /// destination verified and none written.
53    Adopt,
54}
55
56impl Intent {
57    /// The wire form, identical to the serde rendering.
58    #[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/// The plan, whole.
70#[derive(Debug, Serialize, Deserialize)]
71pub struct Plan {
72    /// The shape version of this document.
73    pub schema: std::borrow::Cow<'static, str>,
74    /// Who computed it, when, under which id.
75    pub identity: Identity,
76    /// Which procedure this plan is.
77    pub classification: Classification,
78    /// What the classification compresses.
79    pub findings: Vec<Finding>,
80    /// What the target is asked to converge toward.
81    pub desired_state: DesiredState,
82    /// What the target was found to be.
83    pub observed_state: ObservedState,
84    /// The candidate bundle and what is known about it.
85    pub release: Release,
86    /// The typed changes, in apply order.
87    pub operations: Vec<Operation>,
88    /// Each with its requirement and its evaluation.
89    pub preconditions: Vec<Precondition>,
90    /// The questions the operator owns, with their selected answers.
91    pub decisions: Vec<Decision>,
92    /// The typed checks an apply runs at the end and reports.
93    pub postconditions: Vec<Postcondition>,
94    /// Every observed value, cited by the fields above.
95    pub evidence: Vec<EvidenceItem>,
96    /// Whether the plan may be applied.
97    pub readiness: Readiness,
98    /// One canonical digest over the semantic inputs.
99    pub input_fingerprint: Digest,
100}
101
102/// Who computed the plan, when, and under which id.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Identity {
105    /// Derived from the fingerprint and the creation instant, so two
106    /// plans over the same inputs are distinguishable and one plan is
107    /// not stored twice by accident.
108    pub plan_id: String,
109    /// The instant the plan was computed, RFC 3339.
110    pub created_at: String,
111    /// The engine that computed it.
112    pub engine_version: String,
113}
114
115/// What the target is asked to converge toward.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct DesiredState {
118    /// What the caller asked the plan to be.
119    pub intent: Intent,
120    /// The selector as the operator gave it: `embedded`, `latest`, or an
121    /// exact version.
122    pub selector: String,
123    /// What the selector resolved to, once, frozen here.
124    pub release: ResolvedRelease,
125    /// The landing configuration the projection renders under, or the
126    /// reason none resolved.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub configuration: Option<Configuration>,
129    /// Why the configuration did not resolve, where it did not.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub unresolved: Option<String>,
132}
133
134/// One exact release, resolved at plan time and never again.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ResolvedRelease {
137    /// The exact version.
138    pub version: String,
139    /// Where it was read from: `embedded`, `crates`, or `directory`.
140    pub venue: String,
141    /// The bundle's aggregate digest.
142    pub payload_sha256: Digest,
143    /// The bundle's protocol version.
144    pub payload_schema: u32,
145}
146
147/// The landing parameters, resolved, with the layer each one came from.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Configuration {
150    /// The payload binding.
151    pub tech: String,
152    /// The forge.
153    pub forge: String,
154    /// The project path on the forge.
155    pub repo: String,
156    /// The working-copy mode.
157    pub workflow: String,
158    /// The release style, where one is answered.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub style: Option<String>,
161    /// Whether the landing carries the Nix capability.
162    pub nix: bool,
163    /// The one permanent branch.
164    pub trunk: String,
165    /// The release-line prefix.
166    pub line_prefix: String,
167    /// The security contact the policy names, empty for the forge's own.
168    pub security_contact: String,
169    /// The acknowledgment window the policy promises.
170    pub security_response: String,
171    /// Which layer answered each parameter: `flag`, `configuration`,
172    /// `record`, `detected`, or `default`.
173    pub sources: BTreeMap<String, String>,
174    /// The evidence the resolution read.
175    pub evidence_refs: Vec<String>,
176}
177
178/// What the target was found to be.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ObservedState {
181    /// The repository's own state.
182    pub repository: Repository,
183    /// What release-kit landed there, as far as the disk says.
184    pub installation: Installation,
185    /// The engine and the host.
186    pub host: Host,
187    /// What the forge said, where it was asked.
188    pub forge: ForgeState,
189}
190
191/// The repository's own state, read off the disk and git.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct Repository {
194    /// The target directory.
195    pub target: String,
196    /// Whether the target is a git repository.
197    pub git: bool,
198    /// How many tags it holds.
199    pub tags: usize,
200    /// Long-lived branches beside the trunk.
201    pub long_lived_branches: Vec<String>,
202    /// Other tools' release markers present.
203    pub release_markers: Vec<String>,
204    /// Payload destinations already present.
205    pub collisions: Vec<String>,
206    /// The technology the version file names, where one is found.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub tech: Option<String>,
209    /// The forge the origin remote maps to, where one is recognized.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub forge: Option<String>,
212    /// The project path from the origin remote, where one exists.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub repo: Option<String>,
215    /// The corpus verdict the facts above earn.
216    pub verdict: Verdict,
217    /// The evidence these facts rest on.
218    pub evidence_refs: Vec<String>,
219}
220
221/// What release-kit landed at the target.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Installation {
224    /// The landing record.
225    pub record: RecordState,
226    /// The committed configuration.
227    pub configuration: ConfigurationState,
228    /// Every destination the candidate or the record names, as found.
229    pub destinations: Vec<Destination>,
230    /// The evidence the installation rests on.
231    pub evidence_refs: Vec<String>,
232}
233
234/// The landing record, as found.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(tag = "state", rename_all = "kebab-case")]
237pub enum RecordState {
238    /// No record at the target.
239    Absent,
240    /// A record this engine read.
241    Present {
242        /// The binary that wrote it.
243        rk_version: String,
244        /// The payload that landed.
245        payload_sha256: Digest,
246        /// The record's schema.
247        schema_version: u64,
248        /// How the record came to exist.
249        origin: String,
250        /// The digest of the record's bytes.
251        sha256: Digest,
252    },
253    /// A record this engine could not read.
254    Invalid {
255        /// Why.
256        reason: String,
257    },
258}
259
260/// The committed configuration, as found.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ConfigurationState {
263    /// Whether `.release-kit/config.toml` exists.
264    pub present: bool,
265    /// The digest of its bytes, where present.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub sha256: Option<Digest>,
268    /// Why it did not read, where it did not.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub invalid: Option<String>,
271    /// Keys whose configured answers the record has yet to take up.
272    pub pending: Vec<String>,
273}
274
275/// One destination, as found.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct Destination {
278    /// The destination, relative to the target.
279    pub path: String,
280    /// Whether the file, or the marked block, is present.
281    pub present: bool,
282    /// The digest of what is there, where present.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub sha256: Option<Digest>,
285    /// The kind the record declares for it, where the record names it.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub recorded_kind: Option<Kind>,
288}
289
290/// The engine and the host.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct Host {
293    /// This engine's version.
294    pub engine_version: String,
295    /// The pin the wired manager records for `rk`, where one does.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub pin: Option<PinState>,
298    /// The evidence the host facts rest on.
299    pub evidence_refs: Vec<String>,
300}
301
302/// The `rk` pin a tool manager records.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct PinState {
305    /// The manager.
306    pub manager: String,
307    /// The file that records it.
308    pub file: String,
309    /// The version, as the manager records it.
310    pub version: String,
311}
312
313/// What the forge said, where it was asked.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(tag = "state", rename_all = "kebab-case")]
316pub enum ForgeState {
317    /// The forge was not asked, and the reason says why.
318    NotObserved {
319        /// Why.
320        reason: String,
321    },
322    /// The forge was asked.
323    Observed {
324        /// The trunk the read asked about.
325        trunk: String,
326        /// The trunk's tip at the remote, where it has one.
327        #[serde(skip_serializing_if = "Option::is_none")]
328        remote_tip: Option<String>,
329        /// The evidence the read produced.
330        evidence_refs: Vec<String>,
331    },
332}
333
334/// The candidate bundle and what is known about it.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct Release {
337    /// The candidate's identity.
338    pub candidate: BundleIdentity,
339    /// How the candidate was verified.
340    pub verification: Verification,
341    /// The recorded release's bundle, for the three-way comparison.
342    pub baseline: BaselineState,
343    /// What the engine can say about reading this bundle.
344    pub compatibility: Compatibility,
345    /// The guidance the bundle carries for this target.
346    pub guidance: Guidance,
347}
348
349/// One bundle's identity.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct BundleIdentity {
352    /// The release's version.
353    pub version: String,
354    /// The aggregate digest.
355    pub payload_sha256: Digest,
356    /// The protocol version.
357    pub payload_schema: u32,
358    /// How many artifacts the bundle carries.
359    pub artifacts: usize,
360    /// The evidence the identity rests on.
361    pub evidence_refs: Vec<String>,
362}
363
364/// How a bundle was verified.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(tag = "method", rename_all = "kebab-case")]
367pub enum Verification {
368    /// The bundle is the one compiled into this engine.
369    Embedded,
370    /// The archive digested to the registry's checksum.
371    RegistryChecksum {
372        /// The checksum the index named.
373        cksum: Digest,
374    },
375    /// A directory laid out as a bundle, read as is.
376    Directory,
377}
378
379/// The recorded release's bundle, as read for the baseline.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(tag = "state", rename_all = "kebab-case")]
382pub enum BaselineState {
383    /// No record, so no baseline is needed.
384    NotNeeded,
385    /// The recorded payload is the one compiled into this engine.
386    Embedded,
387    /// The recorded release's bundle was read from the release cache.
388    Cached {
389        /// The recorded version.
390        version: String,
391    },
392    /// The recorded release's bundle could not be read, and the reason
393    /// says why.
394    NotObserved {
395        /// Why.
396        reason: String,
397    },
398}
399
400/// What the engine can say about landing this bundle here.
401///
402/// The protocol axis, and the four axes the bundle declares beyond it.
403/// The facts live here; the preconditions carry each axis's requirement
404/// and evaluation.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct Compatibility {
407    /// The engine's protocol version.
408    pub engine_schema: u32,
409    /// The bundle's protocol version.
410    pub bundle_schema: u32,
411    /// Whether the engine reads the bundle.
412    pub readable: bool,
413    /// The oldest engine the bundle names, where it names one.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub engine_minimum: Option<String>,
416    /// The generator the binding's committed artifact needs, where the
417    /// technology has one.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub generator: Option<GeneratorFact>,
420    /// The forge floor the landed files rest on, where the bundle declares
421    /// one for the configured forge.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub forge_floor: Option<ForgeFloor>,
424    /// The releases between the record and the candidate that a landing
425    /// must pass through.
426    pub intermediate: Vec<IntermediateFact>,
427    /// The evidence the axes rest on.
428    pub evidence_refs: Vec<String>,
429}
430
431/// The generator axis, as observed.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct GeneratorFact {
434    /// The tool, as `versions.toml` names it.
435    pub name: String,
436    /// The version the candidate bundle pins.
437    pub pin: String,
438    /// The committed artifact it regenerates.
439    pub artifact: String,
440    /// The version found on the host, where one was.
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub host: Option<String>,
443}
444
445/// The forge axis, as declared and observed.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct ForgeFloor {
448    /// The forge.
449    pub forge: String,
450    /// The floor the bundle declares.
451    pub minimum: String,
452    /// The version the forge reported, where it was asked.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub observed: Option<String>,
455}
456
457/// One release the landing must pass through.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct IntermediateFact {
460    /// The version.
461    pub version: String,
462    /// Why it cannot be skipped.
463    pub reason: String,
464}
465
466/// The guidance the bundle carries for this target.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct Guidance {
469    /// How much of the interval the bundle describes.
470    pub coverage: Coverage,
471    /// The releases the selection spans, where a record bounds it.
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub interval: Option<Interval>,
474    /// The steps that concern a destination this target has, in version
475    /// order.
476    pub steps: Vec<GuidanceStep>,
477    /// How many steps in the interval concern no destination here.
478    pub excluded: usize,
479    /// The evidence the selection rests on.
480    pub evidence_refs: Vec<String>,
481}
482
483/// How much of the interval the bundle describes. `covered` with no step
484/// is "no applicable steps", and it is not `unavailable`.
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(tag = "state", rename_all = "kebab-case")]
487pub enum Coverage {
488    /// No record bounds an interval, so there is nothing to describe.
489    NotNeeded,
490    /// Every release in the interval is described.
491    Covered,
492    /// The interval reaches below the release the bundle describes from.
493    Partial {
494        /// The release above which the bundle describes every release.
495        since: String,
496    },
497    /// The bundle carries no guidance at all.
498    Unavailable,
499}
500
501/// The releases a selection spans: above `from`, up to and including
502/// `to`.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct Interval {
505    /// The recorded release.
506    pub from: String,
507    /// The candidate release.
508    pub to: String,
509}
510
511/// One release's step, as the target needs it.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct GuidanceStep {
514    /// The release that introduced the change.
515    pub version: String,
516    /// The file's heading.
517    pub title: String,
518    /// The landed paths it concerns, all of them, as authored.
519    pub destinations: Vec<String>,
520    /// `operator-step` or `plan-operation`.
521    pub action: String,
522    /// The authored text below the fields.
523    pub body: String,
524}
525
526/// One question the operator owns.
527#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct Decision {
529    /// A stable id that survives re-planning.
530    pub id: String,
531    /// The question, one line.
532    pub question: String,
533    /// The answers, each with its consequence.
534    pub choices: Vec<Choice>,
535    /// The answer selected, where one is.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub selected: Option<String>,
538}
539
540/// One answer to a decision.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub struct Choice {
543    /// The answer word.
544    pub answer: String,
545    /// What selecting it means.
546    pub consequence: String,
547}
548
549/// One typed check an apply runs at the end and reports.
550#[derive(Debug, Clone, Serialize, Deserialize)]
551#[serde(tag = "check", rename_all = "kebab-case")]
552pub enum Postcondition {
553    /// The record reads back at the planned digest.
554    RecordReadsBack {
555        /// The planned digest.
556        sha256: Digest,
557    },
558    /// A destination holds the planned bytes.
559    DestinationHolds {
560        /// The destination.
561        path: String,
562        /// The planned digest.
563        sha256: Digest,
564    },
565    /// `rk status --check` exits 0.
566    StatusCheckClean,
567    /// The wired manager records the planned version.
568    PinReads {
569        /// The manager.
570        manager: String,
571        /// The planned version.
572        version: String,
573    },
574}
575
576/// One request to compute a plan, as the store keeps it beside the plan
577/// so an apply can compute the same plan again and compare.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct PlanRequest {
580    /// The target, as given.
581    pub target: camino::Utf8PathBuf,
582    /// What the caller asked the plan to be.
583    pub intent: Intent,
584    /// The selector as given: `embedded`, `latest`, or an exact version.
585    pub selector: String,
586    /// Whether a recorded release the cache does not hold is fetched.
587    pub fetch: bool,
588    /// Whether the forge is read.
589    pub observe_forge: bool,
590    /// The explicit answers.
591    pub flags: gather::Flags,
592    /// The decisions selected, by id.
593    pub decisions: BTreeMap<String, String>,
594}
595
596/// A computed plan with the bytes its operations name, and what the
597/// three-way comparison decided per destination, for the fronts that
598/// render a per-file report.
599#[derive(Debug)]
600pub struct Planned {
601    /// The plan.
602    pub plan: Plan,
603    /// Every byte the plan names, by digest: what an operation writes,
604    /// what a destination holds now, and the baseline where it was read.
605    pub blobs: BTreeMap<Digest, Vec<u8>>,
606    /// What the comparison decided for each projected destination, in
607    /// projection order.
608    pub outcomes: Vec<DestinationOutcome>,
609    /// The configuration an apply writes, where the parameters resolved.
610    pub config: Option<crate::config::Plan>,
611    /// The Nix destinations withheld at this target, each with why.
612    pub withheld: Vec<crate::landing::Withheld>,
613}
614
615/// What the three-way comparison decided for one projected destination.
616#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct DestinationOutcome {
618    /// The destination, relative to the target.
619    pub path: String,
620    /// The kind the candidate declares.
621    pub kind: Kind,
622    /// Whether the record names it.
623    pub recorded: bool,
624    /// What happens to it.
625    pub disposition: Disposition,
626}
627
628/// The closed set of things the comparison decides for a destination.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum Disposition {
631    /// The candidate's bytes are written.
632    Write,
633    /// The destination already holds what the candidate would write, or
634    /// what the record left there.
635    Unchanged,
636    /// The target's own bytes stay: a seeded or state file it tuned.
637    Kept,
638    /// A recorded seeded file moved away from its baseline and stays.
639    Drift,
640    /// A recorded state file, never compared.
641    State,
642    /// The target edited a file release-kit owns.
643    Conflict,
644    /// The record names a file the disk does not hold.
645    Missing,
646}
647
648impl Disposition {
649    /// The word a report prints.
650    #[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    /// The complete `rk.plan/3` shape, every section present, held by
682    /// snapshot: a field rename or removal fails here and becomes a
683    /// deliberate schema bump.
684    #[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}