release-kit 0.3.30

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
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
//! 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 apply;
pub mod classify;
pub mod compatibility;
pub mod evidence;
pub mod fingerprint;
pub mod gather;
pub mod guidance;
pub mod lock;
pub mod operation;
pub mod planner;
pub mod readiness;
pub mod store;

use std::collections::BTreeMap;

use serde::{Deserialize, 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/3";

/// What the caller asked the plan to be: the open reconciliation, or one
/// of the three fronts, each of which fixes what the plan may contain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Intent {
    /// `rk reconcile plan`: the classification decides.
    Reconcile,
    /// `rk init`: a first landing, refused over a record.
    Setup,
    /// `rk upgrade`: a recorded target takes the candidate, refused
    /// without a record.
    Upgrade,
    /// `rk adopt`: the record and the configuration alone, every
    /// destination verified and none written.
    Adopt,
}

impl Intent {
    /// The wire form, identical to the serde rendering.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Reconcile => "reconcile",
            Self::Setup => "setup",
            Self::Upgrade => "upgrade",
            Self::Adopt => "adopt",
        }
    }
}

/// The plan, whole.
#[derive(Debug, Serialize, Deserialize)]
pub struct Plan {
    /// The shape version of this document.
    pub schema: std::borrow::Cow<'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, Deserialize)]
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, Deserialize)]
pub struct DesiredState {
    /// What the caller asked the plan to be.
    pub intent: Intent,
    /// 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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
#[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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
#[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, Deserialize)]
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, Deserialize)]
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, Deserialize)]
#[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, Deserialize)]
#[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 landing this bundle here.
///
/// The protocol axis, and the four axes the bundle declares beyond it.
/// The facts live here; the preconditions carry each axis's requirement
/// and evaluation.
#[derive(Debug, Clone, Serialize, Deserialize)]
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 oldest engine the bundle names, where it names one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub engine_minimum: Option<String>,
    /// The generator the binding's committed artifact needs, where the
    /// technology has one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub generator: Option<GeneratorFact>,
    /// The forge floor the landed files rest on, where the bundle declares
    /// one for the configured forge.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub forge_floor: Option<ForgeFloor>,
    /// The releases between the record and the candidate that a landing
    /// must pass through.
    pub intermediate: Vec<IntermediateFact>,
    /// The evidence the axes rest on.
    pub evidence_refs: Vec<String>,
}

/// The generator axis, as observed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratorFact {
    /// The tool, as `versions.toml` names it.
    pub name: String,
    /// The version the candidate bundle pins.
    pub pin: String,
    /// The committed artifact it regenerates.
    pub artifact: String,
    /// The version found on the host, where one was.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub host: Option<String>,
}

/// The forge axis, as declared and observed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeFloor {
    /// The forge.
    pub forge: String,
    /// The floor the bundle declares.
    pub minimum: String,
    /// The version the forge reported, where it was asked.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub observed: Option<String>,
}

/// One release the landing must pass through.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntermediateFact {
    /// The version.
    pub version: String,
    /// Why it cannot be skipped.
    pub reason: String,
}

/// The guidance the bundle carries for this target.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Guidance {
    /// How much of the interval the bundle describes.
    pub coverage: Coverage,
    /// The releases the selection spans, where a record bounds it.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub interval: Option<Interval>,
    /// The steps that concern a destination this target has, in version
    /// order.
    pub steps: Vec<GuidanceStep>,
    /// How many steps in the interval concern no destination here.
    pub excluded: usize,
    /// The evidence the selection rests on.
    pub evidence_refs: Vec<String>,
}

/// How much of the interval the bundle describes. `covered` with no step
/// is "no applicable steps", and it is not `unavailable`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum Coverage {
    /// No record bounds an interval, so there is nothing to describe.
    NotNeeded,
    /// Every release in the interval is described.
    Covered,
    /// The interval reaches below the release the bundle describes from.
    Partial {
        /// The release above which the bundle describes every release.
        since: String,
    },
    /// The bundle carries no guidance at all.
    Unavailable,
}

/// The releases a selection spans: above `from`, up to and including
/// `to`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interval {
    /// The recorded release.
    pub from: String,
    /// The candidate release.
    pub to: String,
}

/// One release's step, as the target needs it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuidanceStep {
    /// The release that introduced the change.
    pub version: String,
    /// The file's heading.
    pub title: String,
    /// The landed paths it concerns, all of them, as authored.
    pub destinations: Vec<String>,
    /// `operator-step` or `plan-operation`.
    pub action: String,
    /// The authored text below the fields.
    pub body: String,
}

/// One question the operator owns.
#[derive(Debug, Clone, Serialize, Deserialize)]
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, Deserialize)]
pub struct Choice {
    /// The answer word.
    pub answer: String,
    /// What selecting it means.
    pub consequence: String,
}

/// Every decision this engine asks, with the closed set of answers each
/// one takes.
///
/// A decision is a question the operator owns, and its choices are the
/// whole of what answers it. The catalogue is the one place that pairing
/// lives, so the parse that reads `--decide` and the evaluation that
/// judges a stored answer agree by construction. A decision the planner
/// adds without an entry here fails
/// [`every_decision_the_planner_asks_is_in_the_catalogue`].
pub const DECISION_CHOICES: [(&str, &[&str]); 6] = [
    ("workflow-mode", &["worktree", "branches"]),
    ("release-style", &["trunk", "lines"]),
    ("release-activity", &["history", "migrate"]),
    ("partial-baseline", &["accept", "fetch"]),
    (guidance::PARTIAL_GUIDANCE_DECISION, &["accept"]),
    (compatibility::PIN_MANAGER_DECISION, &["wire", "host"]),
];

/// The answers one decision takes, where the catalogue names it.
#[must_use]
pub fn decision_choices(id: &str) -> Option<&'static [&'static str]> {
    DECISION_CHOICES
        .iter()
        .find(|(known, _)| *known == id)
        .map(|(_, choices)| *choices)
}

/// Whether `answer` is one this decision declares.
///
/// An id the catalogue does not name answers `false`: an unknown
/// decision has no answer that satisfies it.
#[must_use]
pub fn decision_answered(id: &str, answer: Option<&str>) -> bool {
    match (decision_choices(id), answer) {
        (Some(choices), Some(answer)) => choices.contains(&answer),
        _ => false,
    }
}

/// One typed check an apply runs at the end and reports.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[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,
    },
}

/// One request to compute a plan, as the store keeps it beside the plan
/// so an apply can compute the same plan again and compare.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanRequest {
    /// The target, absolute and symlink-resolved, so a stored plan names
    /// one checkout and never whichever directory an apply runs in.
    pub target: camino::Utf8PathBuf,
    /// What the caller asked the plan to be.
    pub intent: Intent,
    /// The selector as given: `embedded`, `latest`, or an exact version.
    pub selector: String,
    /// Whether a recorded release the cache does not hold is fetched.
    pub fetch: bool,
    /// Whether the forge is read.
    pub observe_forge: bool,
    /// The explicit answers.
    pub flags: gather::Flags,
    /// The decisions selected, by id.
    pub decisions: BTreeMap<String, String>,
}

impl PlanRequest {
    /// The same request with its target resolved to an absolute,
    /// symlink-free path.
    ///
    /// Every construction site takes this before the request reaches the
    /// planner or the store. A relative target would otherwise resolve
    /// against whichever directory an apply runs in, so a plan approved
    /// against one checkout could land in another whose content happens
    /// to match, which the fingerprint alone cannot catch.
    ///
    /// # Errors
    ///
    /// [`RkError::Usage`] where the target does not exist or cannot be
    /// resolved, and [`RkError::Other`] where the resolved path is not
    /// valid UTF-8.
    pub fn canonicalized(mut self) -> Result<Self, crate::error::RkError> {
        self.target = canonical_target(&self.target)?;
        Ok(self)
    }
}

/// One target directory as an absolute, symlink-free path.
///
/// # Errors
///
/// [`RkError::Usage`] where the path does not exist or cannot be
/// resolved, and [`RkError::Other`] where it is not valid UTF-8.
pub fn canonical_target(
    target: &camino::Utf8Path,
) -> Result<camino::Utf8PathBuf, crate::error::RkError> {
    let resolved = std::fs::canonicalize(target).map_err(|error| {
        crate::error::RkError::Usage(format!(
            "the target {target} does not resolve to a directory: {error}"
        ))
    })?;
    camino::Utf8PathBuf::from_path_buf(resolved).map_err(|path| {
        crate::error::RkError::Other(anyhow::anyhow!(
            "the target resolves to {}, which is not valid UTF-8",
            path.display()
        ))
    })
}

/// A computed plan with the bytes its operations name, and what the
/// three-way comparison decided per destination, for the fronts that
/// render a per-file report.
#[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>>,
    /// What the comparison decided for each projected destination, in
    /// projection order.
    pub outcomes: Vec<DestinationOutcome>,
    /// The configuration an apply writes, where the parameters resolved.
    pub config: Option<crate::config::Plan>,
    /// The Nix destinations withheld at this target, each with why.
    pub withheld: Vec<crate::landing::Withheld>,
}

/// What the three-way comparison decided for one projected destination.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DestinationOutcome {
    /// The destination, relative to the target.
    pub path: String,
    /// The kind the candidate declares.
    pub kind: Kind,
    /// Whether the record names it.
    pub recorded: bool,
    /// What happens to it.
    pub disposition: Disposition,
}

/// The closed set of things the comparison decides for a destination.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposition {
    /// The candidate's bytes are written.
    Write,
    /// The destination already holds what the candidate would write, or
    /// what the record left there.
    Unchanged,
    /// The target's own bytes stay: a seeded or state file it tuned.
    Kept,
    /// A recorded seeded file moved away from its baseline and stays.
    Drift,
    /// A recorded state file, never compared.
    State,
    /// The target edited a file release-kit owns.
    Conflict,
    /// The record names a file the disk does not hold.
    Missing,
}

impl Disposition {
    /// The word a report prints.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Write => "write",
            Self::Unchanged => "unchanged",
            Self::Kept => "kept",
            Self::Drift => "drift",
            Self::State => "state",
            Self::Conflict => "conflict",
            Self::Missing => "missing",
        }
    }
}

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

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

    /// The complete `rk.plan/3` 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.into(),
            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".into(),
                detail: "SECURITY.md".into(),
            }],
            desired_state: DesiredState {
                intent: Intent::Reconcile,
                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,
                    engine_minimum: Some("0.0.0".into()),
                    generator: Some(GeneratorFact {
                        name: "cargo-dist".into(),
                        pin: "0.32.0".into(),
                        artifact: "dist-workspace.toml".into(),
                        host: Some("0.32.0".into()),
                    }),
                    forge_floor: Some(ForgeFloor {
                        forge: "gitlab".into(),
                        minimum: "18.2".into(),
                        observed: Some("18.2.0".into()),
                    }),
                    intermediate: vec![IntermediateFact {
                        version: "0.0.0".into(),
                        reason: "the record changed shape".into(),
                    }],
                    evidence_refs: vec!["candidate-bundle".into()],
                },
                guidance: Guidance {
                    coverage: Coverage::Partial {
                        since: "0.0.0".into(),
                    },
                    interval: Some(Interval {
                        from: "0.0.0".into(),
                        to: "0.0.0".into(),
                    }),
                    steps: vec![GuidanceStep {
                        version: "0.0.0".into(),
                        title: "release-kit 0.0.0".into(),
                        destinations: vec![".envrc".into()],
                        action: "operator-step".into(),
                        body: "## What to do".into(),
                    }],
                    excluded: 1,
                    evidence_refs: vec!["candidate-bundle".into()],
                },
            },
            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/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}"}}"###
        );
        assert_eq!(json, expected);
    }

    /// Every decision the planner asks carries its choices in the
    /// catalogue, and every catalogued answer is one a choice declares.
    ///
    /// The catalogue is what the parse and the evaluation both read, so
    /// a decision added to the planner without an entry here would take
    /// any answer at the parse and satisfy nothing at the evaluation.
    #[test]
    fn every_decision_the_planner_asks_is_in_the_catalogue() {
        let sources = [
            include_str!("planner.rs"),
            include_str!("compatibility.rs"),
            include_str!("guidance.rs"),
        ];
        // Every id the catalogue names is asked somewhere, so an entry
        // does not outlive the decision it describes.
        for (id, choices) in DECISION_CHOICES {
            assert!(
                sources.iter().any(|source| source.contains(id)),
                "the catalogue names {id} and no module asks it"
            );
            assert!(!choices.is_empty(), "{id} declares no answer");
            for answer in choices {
                assert!(
                    decision_answered(id, Some(answer)),
                    "{id} does not take its own declared answer {answer}"
                );
            }
            assert!(
                !decision_answered(id, Some("not-a-declared-answer")),
                "{id} takes an answer it does not declare"
            );
        }
        assert!(decision_choices("not-a-decision").is_none());
        assert!(!decision_answered("not-a-decision", Some("anything")));
    }
}