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