Skip to main content

release_kit/landing/
manifest.rs

1//! The landing record: `.release-kit/manifest.json`.
2//!
3//! The record is a manifest, not a stamp: `rk status` and `rk upgrade`
4//! make decisions from it, so it earns a parser that can fail and a
5//! stated schema version — an unknown shape refuses naming the record,
6//! never a best-effort read. It is written last, after every file has
7//! landed, through the temp-plus-rename writer, and it is committed:
8//! every reader it exists for sees only committed files, and it carries
9//! digests of committed files, nothing secret and nothing
10//! machine-specific.
11
12use std::collections::BTreeMap;
13
14use camino::Utf8Path;
15use serde::{Deserialize, Serialize};
16
17use crate::atomic;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::Kind;
22
23/// Where the record lives, relative to the target root.
24pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
25
26/// The schema this binary writes.
27///
28/// Schema 7 is the receipt of a direct landing: the producing
29/// `rk_version`, the origin, the resolved parameters, and per destination
30/// the path, the kind, the placement where the destination is a marked
31/// region, and the digest of the bytes or region now present. It carries
32/// no payload digest and no baseline digest, because the landing renders
33/// afresh from this binary and compares against no earlier release.
34///
35/// Schemas 1 through 6 read through one bounded conversion in
36/// [`legacy`]: the retired `payload_sha256`, per-file `baseline_sha256`,
37/// and `parameters.scopes` fields are dropped, and the parameters a
38/// record predates take the defaults such a landing wrote. The next
39/// successful landing rewrites schema 7. Anything past this schema
40/// refuses by name.
41///
42/// SATISFIES landing:a-record-states-its-schema
43pub const SCHEMA_VERSION: u64 = 7;
44
45/// The oldest schema this binary still reads.
46const OLDEST_READABLE_SCHEMA: u64 = 1;
47
48/// The working-copy mode a landing records: a project decision, rendered
49/// into the landed blocks and changed only through the landing verbs.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum Workflow {
53    /// Every code-changing branch lives in a linked worktree and the main
54    /// checkout commits nothing.
55    Worktree,
56    /// Branches are worked in the main checkout; worktrees stay available
57    /// beside them and nothing refuses either form.
58    Branches,
59}
60
61impl Workflow {
62    /// The flag, wire, and report form.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Worktree => "worktree",
67            Self::Branches => "branches",
68        }
69    }
70
71    /// Parse a `--workflow` flag value.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`RkError::Usage`] naming the two values.
76    pub fn parse(raw: &str) -> Result<Self, RkError> {
77        match raw {
78            "worktree" => Ok(Self::Worktree),
79            "branches" => Ok(Self::Branches),
80            other => Err(RkError::Usage(format!(
81                "unknown workflow '{other}'; the modes are: worktree, branches"
82            ))),
83        }
84    }
85}
86
87/// The serde default for a record from before the parameter existed.
88const fn workflow_branches() -> Workflow {
89    Workflow::Branches
90}
91
92/// The release style a landing records.
93///
94/// Whether the bot's release request stands armed to merge itself: a
95/// project decision, rendered into the landed release workflow and
96/// changed only through the landing verbs.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum Style {
100    /// The trunk style: the release request carries auto-merge from
101    /// creation, so a green trunk ships itself.
102    Trunk,
103    /// The lines style: every request waits for a human's merge, because
104    /// a line's candidate is validated by hand.
105    Lines,
106}
107
108impl Style {
109    /// The flag, wire, and report form.
110    #[must_use]
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Trunk => "trunk",
114            Self::Lines => "lines",
115        }
116    }
117
118    /// Parse a `--style` flag value.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`RkError::Usage`] naming the two values.
123    pub fn parse(raw: &str) -> Result<Self, RkError> {
124        match raw {
125            "trunk" => Ok(Self::Trunk),
126            "lines" => Ok(Self::Lines),
127            other => Err(RkError::Usage(format!(
128                "unknown style '{other}'; the styles are: trunk, lines"
129            ))),
130        }
131    }
132}
133
134/// The record a landing writes and every target-side verb reads.
135#[derive(Debug, Serialize, Deserialize)]
136pub struct Manifest {
137    /// An integer this binary either knows or refuses on.
138    pub schema_version: u64,
139    /// The binary that produced the landing.
140    pub rk_version: String,
141    /// `init` or `adopt` — how the record came to exist.
142    pub origin: String,
143    /// The technology that selected the payload.
144    pub tech: String,
145    /// The forge that selected the payload.
146    pub forge: String,
147    /// When the first landing happened; an upgrade preserves it.
148    pub landed_at: String,
149    /// Every value substituted into a `rendered` file, so a re-render is
150    /// reproducible without asking again.
151    pub parameters: Parameters,
152    /// Every landed destination with its kind and digests.
153    pub files: Vec<FileRecord>,
154    /// The registry pins the landed technology uses, copied at landing
155    /// time; `rk status` compares them offline.
156    pub pins: BTreeMap<String, String>,
157}
158
159/// The landing parameters, recorded whole.
160#[derive(Debug, Serialize, Deserialize)]
161pub struct Parameters {
162    /// The project path on the forge, recorded whole because a GitLab
163    /// project may nest below its group.
164    pub repo: String,
165    /// The working-copy mode the project chose: every code-changing branch
166    /// in a linked worktree (`worktree`), or branches worked in the main
167    /// checkout with worktrees optional beside them (`branches`). A record
168    /// predating the field reads as `branches`, so an upgrade never imposes
169    /// a guard the project did not choose.
170    #[serde(default = "workflow_branches")]
171    pub workflow: Workflow,
172    /// The release style the project chose: the bot's request armed to
173    /// merge itself (`trunk`), or every merge a human's (`lines`). A
174    /// record predating the field carries none, and an upgrade refuses
175    /// until `--style` names one: neither value is a compatibility-safe
176    /// reading of a target nobody asked.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub style: Option<Style>,
179    /// Whether the landing carries the Nix capability: the seeded package
180    /// expression, the flake pair where the target had none, and the
181    /// workflow that proves the build. A record predating the field reads
182    /// as opt-out, so an upgrade adds nothing unrequested; the projection
183    /// stays reproducible from the record because this field is part of
184    /// it.
185    #[serde(default)]
186    pub nix: bool,
187    /// The one permanent branch, rendered into every landed artifact that
188    /// names it. A record predating the field reads as `master`, which is
189    /// what such a landing wrote, so the projection stays reproducible.
190    #[serde(default = "trunk_master")]
191    pub trunk: String,
192    /// The release-line branch prefix, rendered into the release triggers
193    /// and branch guards. A record predating the field reads as
194    /// `release/`, which is what such a landing wrote.
195    #[serde(default = "line_prefix_release")]
196    pub line_prefix: String,
197    /// The contact the landed policy names where the forge's own channel
198    /// is unavailable, empty for the forge's authored wording. A record
199    /// predating the field reads as empty, which is what such a landing
200    /// wrote.
201    #[serde(default, deserialize_with = "read_contact")]
202    pub security_contact: String,
203    /// The acknowledgment window the landed policy promises. A record
204    /// predating the field reads as `best-effort`, which is what such a
205    /// landing wrote.
206    #[serde(default = "response_best_effort", deserialize_with = "read_response")]
207    pub security_response: String,
208}
209
210/// The trunk a record predating the field carries.
211fn trunk_master() -> String {
212    crate::config::TRUNK_DEFAULT.to_owned()
213}
214
215/// The prefix a record predating the field carries.
216fn line_prefix_release() -> String {
217    crate::config::LINE_PREFIX_DEFAULT.to_owned()
218}
219
220/// The stance a record predating the field carries.
221fn response_best_effort() -> String {
222    crate::config::RESPONSE_DEFAULT.to_owned()
223}
224
225/// A recorded contact, refused where the configuration reader would refuse
226/// it or where it is not already canonical.
227///
228/// The record is the one input a re-render reads, so a hand-edited record
229/// must not reach bytes the configured path could never have produced.
230fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
231    canonical(reader, "security_contact", crate::config::canonical_contact)
232}
233
234/// A recorded response stance, held to the same grammar as the key.
235fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
236    canonical(
237        reader,
238        "security_response",
239        crate::config::canonical_response,
240    )
241}
242
243/// One recorded string held to its canonical form.
244fn canonical<'de, D: serde::Deserializer<'de>>(
245    reader: D,
246    field: &str,
247    judge: impl Fn(&str) -> Result<String, String>,
248) -> Result<String, D::Error> {
249    let raw = String::deserialize(reader)?;
250    let canonical = judge(&raw)
251        .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
252    if canonical == raw {
253        Ok(canonical)
254    } else {
255        Err(serde::de::Error::custom(format!(
256            "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
257        )))
258    }
259}
260
261/// One landed destination.
262#[derive(Debug, Serialize, Deserialize)]
263pub struct FileRecord {
264    /// The destination, relative to the target root.
265    pub destination: String,
266    /// The declared ownership kind.
267    pub kind: Kind,
268    /// The digest of what the destination holds: the bytes now present
269    /// for a whole file, the marked region alone for a region destination.
270    pub sha256: Digest,
271    /// How the landing occupies the destination: the whole file, which
272    /// the record omits, or one marked region inside a document the
273    /// target owns.
274    #[serde(default, skip_serializing_if = "Placement::is_whole")]
275    pub placement: Placement,
276}
277
278/// How a recorded destination is occupied.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
280#[serde(rename_all = "lowercase")]
281pub enum Placement {
282    /// The landing owns the whole file.
283    #[default]
284    Whole,
285    /// The landing owns the one marked region; every byte outside it is
286    /// the target's.
287    Region,
288}
289
290impl Placement {
291    /// Whether this is the default the record omits.
292    #[must_use]
293    pub const fn is_whole(&self) -> bool {
294        matches!(self, Self::Whole)
295    }
296
297    /// The report form.
298    #[must_use]
299    pub const fn as_str(self) -> &'static str {
300        match self {
301            Self::Whole => "whole",
302            Self::Region => "region",
303        }
304    }
305}
306
307/// The one bounded conversion from a record at schemas 1 through 6 to the
308/// current shape.
309///
310/// It reads no other release and interprets no payload: it drops the
311/// fields the direct landing retired and lets the serde defaults on
312/// [`Parameters`] answer what an older record left unsaid.
313pub mod legacy {
314    /// Drop every retired field from a record value at a schema before
315    /// this binary's, so it deserializes as the current shape.
316    ///
317    /// `payload_sha256` named a bundle digest no comparison reads any
318    /// more; per-file `baseline_sha256` fed a three-way comparison that
319    /// no longer exists; `parameters.scopes` was a vocabulary this binary
320    /// renders nowhere.
321    pub fn convert(mut value: serde_json::Value) -> serde_json::Value {
322        if let Some(record) = value.as_object_mut() {
323            record.remove("payload_sha256");
324            if let Some(parameters) = record
325                .get_mut("parameters")
326                .and_then(serde_json::Value::as_object_mut)
327            {
328                parameters.remove("scopes");
329            }
330            if let Some(files) = record
331                .get_mut("files")
332                .and_then(serde_json::Value::as_array_mut)
333            {
334                for file in files
335                    .iter_mut()
336                    .filter_map(serde_json::Value::as_object_mut)
337                {
338                    file.remove("baseline_sha256");
339                }
340            }
341        }
342        value
343    }
344}
345
346impl Manifest {
347    /// The recorded entry for one destination, where the record names it.
348    #[must_use]
349    pub fn file(&self, destination: &str) -> Option<&FileRecord> {
350        self.files
351            .iter()
352            .find(|file| file.destination == destination)
353    }
354}
355
356/// Read the record at `target`, or `None` where no landing exists.
357///
358/// # Errors
359///
360/// The record's stated failure taxonomy: an unreadable record is a
361/// refusal naming it, a record at an unknown `schema_version` is a
362/// refusal naming the record, and one that does not parse at a known
363/// schema is a defect-class failure.
364pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
365    let path = target.join(MANIFEST_PATH);
366    let bytes = match std::fs::read(&path) {
367        Ok(bytes) => bytes,
368        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
369        Err(e) => {
370            return Err(RkError::refusal(
371                Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
372                    .expected("a readable landing record")
373                    .target_state("unchanged"),
374            ));
375        }
376    };
377    let value: serde_json::Value = serde_json::from_slice(&bytes)
378        .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
379    // A record at an earlier schema converts through the one legacy
380    // conversion. Anything past this binary's schema refuses by the
381    // record schema alone: the record decides whether a guard is landed,
382    // and an older binary must never silently ignore that.
383    let schema = value
384        .get("schema_version")
385        .and_then(serde_json::Value::as_u64);
386    if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
387        let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
388        return Err(RkError::refusal(
389            Diagnostic::new(
390                Reason::UnsupportedSchema,
391                format!(
392                    "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
393                ),
394            )
395            .expected("a landing record at a schema this binary knows")
396            .action("install the rk release that wrote this record, or a newer one")
397            .target_state("unchanged"),
398        ));
399    }
400    let declared = schema.unwrap_or(SCHEMA_VERSION);
401    let value = if declared < SCHEMA_VERSION {
402        legacy::convert(value)
403    } else {
404        value
405    };
406    let mut manifest: Manifest = serde_json::from_value(value)
407        .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
408    // A record before schema 7 stated no placement: the block destinations
409    // were regions by their names alone, and the loaded shape says so.
410    for file in &mut manifest.files {
411        if declared < SCHEMA_VERSION && crate::landing::block_markers(&file.destination).is_some() {
412            file.placement = Placement::Region;
413        }
414    }
415    Ok(Some(manifest))
416}
417
418/// Write the record, last, through the temp-plus-rename writer.
419///
420/// # Errors
421///
422/// Any write failure; the destination then holds what it held.
423pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
424    let path = target.join(MANIFEST_PATH);
425    atomic::write(path.as_std_path(), &render(manifest)?)?;
426    Ok(())
427}
428
429/// The bytes [`write`] puts on disk for a record.
430///
431/// # Errors
432///
433/// A serialization failure, which is a defect in this binary.
434pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
435    let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
436    Ok(format!("{text}\n").into_bytes())
437}
438
439/// The current instant in the record's RFC 3339 form.
440#[must_use]
441pub fn now() -> String {
442    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
443}
444
445/// How a record's `rk_version` stands against this binary's.
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
447#[serde(rename_all = "kebab-case")]
448pub enum Alignment {
449    /// The landing came from this binary's version.
450    Aligned,
451    /// The binary is newer; `rk upgrade` takes the target forward.
452    BinaryNewer,
453    /// The landing came from a newer `rk` than this one, which an upgrade
454    /// refuses rather than downgrading.
455    TargetNewer,
456}
457
458impl Alignment {
459    /// The wire form, identical to the serde rendering.
460    #[must_use]
461    pub const fn as_str(self) -> &'static str {
462        match self {
463            Self::Aligned => "aligned",
464            Self::BinaryNewer => "binary-newer",
465            Self::TargetNewer => "target-newer",
466        }
467    }
468}
469
470/// Compare a record's version against this binary's.
471#[must_use]
472pub fn alignment(recorded: &str, binary: &str) -> Alignment {
473    // Build metadata after `+` carries no precedence.
474    let recorded = recorded
475        .split_once('+')
476        .map_or(recorded, |(version, _)| version);
477    let binary = binary
478        .split_once('+')
479        .map_or(binary, |(version, _)| version);
480    let recorded_core = numeric_core(recorded);
481    let binary_core = numeric_core(binary);
482    match binary_core.cmp(&recorded_core) {
483        std::cmp::Ordering::Greater => Alignment::BinaryNewer,
484        std::cmp::Ordering::Less => Alignment::TargetNewer,
485        std::cmp::Ordering::Equal => {
486            // Equal numeric cores: a pre-release is older than the plain
487            // release it precedes, and two pre-releases compare by semver
488            // precedence — dot-separated identifiers, numeric ones
489            // numerically and below alphanumeric ones.
490            let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
491            let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
492            match (recorded_pre, binary_pre) {
493                (Some(_), None) => Alignment::BinaryNewer,
494                (None, Some(_)) => Alignment::TargetNewer,
495                (None, None) => Alignment::Aligned,
496                (Some(r), Some(b)) => match prerelease_cmp(b, r) {
497                    std::cmp::Ordering::Greater => Alignment::BinaryNewer,
498                    std::cmp::Ordering::Less => Alignment::TargetNewer,
499                    std::cmp::Ordering::Equal => Alignment::Aligned,
500                },
501            }
502        }
503    }
504}
505
506/// Whether `candidate` is ahead of `pinned`, by the same ordering the
507/// alignment uses.
508#[must_use]
509pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
510    alignment(pinned, candidate) == Alignment::BinaryNewer
511}
512
513/// Semver pre-release precedence: identifier by identifier, numeric ones
514/// numerically and below any alphanumeric one, and — all preceding
515/// identifiers equal — the longer list wins. An all-digit identifier
516/// compares by digit count and then lexically, which is numeric order at
517/// any length — semver forbids leading zeroes — so no integer parse can
518/// overflow into a wrong answer.
519fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
520    let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
521    let mut left = a.split('.');
522    let mut right = b.split('.');
523    loop {
524        match (left.next(), right.next()) {
525            (None, None) => return std::cmp::Ordering::Equal,
526            (None, Some(_)) => return std::cmp::Ordering::Less,
527            (Some(_), None) => return std::cmp::Ordering::Greater,
528            (Some(x), Some(y)) => {
529                let ordering = match (numeric(x), numeric(y)) {
530                    (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
531                    (true, false) => std::cmp::Ordering::Less,
532                    (false, true) => std::cmp::Ordering::Greater,
533                    (false, false) => x.cmp(y),
534                };
535                if ordering != std::cmp::Ordering::Equal {
536                    return ordering;
537                }
538            }
539        }
540    }
541}
542
543/// The dotted numeric components before any pre-release suffix.
544fn numeric_core(version: &str) -> Vec<u64> {
545    let core = version.split_once('-').map_or(version, |(core, _)| core);
546    core.split('.')
547        .map(|part| part.parse::<u64>().unwrap_or(0))
548        .collect()
549}
550
551#[cfg(test)]
552mod tests {
553    use super::{
554        Alignment, FileRecord, Manifest, Parameters, Placement, Style, Workflow, alignment,
555    };
556    use crate::digest::Digest;
557    use crate::landing::Kind;
558
559    /// The complete record shape at schema 7, held by snapshot: a field
560    /// rename or removal fails here and becomes a schema-version bump
561    /// instead of a silent break at every reader.
562    #[test]
563    fn the_manifest_schema_snapshot_holds() {
564        let manifest = Manifest {
565            schema_version: 7,
566            rk_version: "0.1.0".into(),
567            origin: "init".into(),
568            tech: "rust".into(),
569            forge: "github".into(),
570            landed_at: "2026-08-29T00:00:00Z".into(),
571            parameters: Parameters {
572                repo: "acme/widget".into(),
573                workflow: Workflow::Worktree,
574                style: Some(Style::Trunk),
575                nix: true,
576                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
577                line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
578                security_contact: String::new(),
579                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
580            },
581            files: vec![
582                FileRecord {
583                    destination: "release-plz.toml".into(),
584                    kind: Kind::Seeded,
585                    sha256: Digest::of(b""),
586                    placement: Placement::Whole,
587                },
588                FileRecord {
589                    destination: "AGENTS.md".into(),
590                    kind: Kind::Rendered,
591                    sha256: Digest::of(b""),
592                    placement: Placement::Region,
593                },
594            ],
595            pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
596        };
597        let empty = Digest::of(b"").to_string();
598        let text = serde_json::to_string(&manifest).expect("a manifest serializes");
599        assert_eq!(
600            text,
601            format!(
602                r#"{{"schema_version":7,"rk_version":"0.1.0","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","workflow":"worktree","style":"trunk","nix":true,"trunk":"master","line_prefix":"release/","security_contact":"","security_response":"best-effort"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
603            ),
604            "a whole file omits its placement, and no retired digest field survives"
605        );
606        assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
607    }
608
609    /// A record written before the mode existed reads as `branches`, and
610    /// its scope vocabulary drops, because this binary renders none. Every
611    /// earlier schema converts through the one legacy path with its
612    /// retired digests ignored, and a record past this binary's schema
613    /// refuses by the record schema alone, naming no other schema.
614    #[test]
615    fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
616        let dir = tempfile::tempdir().expect("a scratch target exists");
617        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
618        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
619        let record = |schema: u64| {
620            format!(
621                r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","scopes":["api"]}},"files":[],"pins":{{}}}}"#
622            )
623        };
624        std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
625        let manifest = super::load(target)
626            .expect("a schema-1 record loads")
627            .expect("the record exists");
628        assert_eq!(manifest.parameters.workflow, Workflow::Branches);
629        assert_eq!(
630            manifest.parameters.style, None,
631            "a pre-style record carries no style; the upgrade demands one"
632        );
633        assert!(
634            !manifest.parameters.nix,
635            "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
636        );
637        assert_eq!(
638            manifest.parameters.security_contact, "",
639            "a pre-policy record names no contact, which is what its policy landed"
640        );
641        assert_eq!(
642            manifest.parameters.security_response,
643            crate::config::RESPONSE_DEFAULT,
644            "a pre-policy record promises no window, which is what its policy landed"
645        );
646
647        for schema in 2..=6 {
648            std::fs::write(
649                target.join(super::MANIFEST_PATH),
650                format!(
651                    r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget"}},"files":[{{"destination":"AGENTS.md","kind":"rendered","sha256":"0000000000000000000000000000000000000000000000000000000000000000","baseline_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}}],"pins":{{}}}}"#
652                ),
653            )
654            .expect("the record writes");
655            let manifest = super::load(target)
656                .expect("an earlier record loads")
657                .expect("the record exists");
658            assert_eq!(manifest.schema_version, schema);
659            assert_eq!(
660                manifest.files[0].placement,
661                Placement::Region,
662                "a block destination reads as a region"
663            );
664            let rewritten = super::render(&manifest).expect("renders");
665            let text = String::from_utf8(rewritten).expect("text");
666            assert!(!text.contains("baseline_sha256"), "{text}");
667        }
668
669        std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
670        let refused = super::load(target).expect_err("a schema-999 record refuses");
671        assert_eq!(
672            refused.reason(),
673            crate::diagnostic::Reason::UnsupportedSchema
674        );
675        let message = refused.to_string();
676        assert!(message.contains("999"), "{message}");
677        assert!(message.contains(super::MANIFEST_PATH), "{message}");
678        assert!(
679            !message.to_lowercase().contains("payload"),
680            "the record schema stands alone: {message}"
681        );
682    }
683
684    /// The record is the one input a re-render reads, so a hand-edited
685    /// record must not reach bytes the configured path could never write:
686    /// a value the configuration reader refuses, and a value it would
687    /// canonicalize, both refuse at deserialization.
688    #[test]
689    fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
690        let dir = tempfile::tempdir().expect("a scratch target exists");
691        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
692        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
693        for (field, value) in [
694            // A JSON escape, so the record parses and the value it decodes
695            // to is the line feed the policy could never carry.
696            ("security_contact", "team@acme.example\\nsecond line"),
697            ("security_contact", "  team@acme.example  "),
698            ("security_response", "90d"),
699            ("security_response", "0 days"),
700            ("security_response", "07 days"),
701            ("security_response", "1 days"),
702            ("security_response", ""),
703        ] {
704            let record = format!(
705                r#"{{"schema_version":7,"rk_version":"0.1.0","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
706            );
707            std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
708            let refused = super::load(target).expect_err("an uncanonical record refuses");
709            assert!(refused.to_string().contains(field), "{field}: {refused}");
710        }
711    }
712
713    #[test]
714    fn alignment_orders_versions_numerically() {
715        assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
716        assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
717        assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
718        assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
719        assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
720    }
721
722    /// Pre-release identifiers order by semver precedence, not by text:
723    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
724    /// landing from `rc.10` rather than downgrade it — at any identifier
725    /// length, so no integer width bounds the protection.
726    #[test]
727    fn alignment_orders_numeric_prerelease_identifiers_numerically() {
728        assert_eq!(
729            alignment("0.1.0-rc.10", "0.1.0-rc.2"),
730            Alignment::TargetNewer
731        );
732        assert_eq!(
733            alignment("0.1.0-rc.2", "0.1.0-rc.10"),
734            Alignment::BinaryNewer
735        );
736        assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
737        assert_eq!(
738            alignment("0.1.0-alpha", "0.1.0-alpha.1"),
739            Alignment::BinaryNewer
740        );
741        assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
742        assert_eq!(
743            alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
744            Alignment::TargetNewer,
745            "identifiers past the u64 range still compare numerically"
746        );
747        assert_eq!(
748            alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
749            Alignment::BinaryNewer
750        );
751    }
752
753    /// Build metadata carries no precedence: it never corrupts a numeric
754    /// component and never separates two otherwise-equal versions.
755    #[test]
756    fn alignment_ignores_build_metadata() {
757        assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
758        assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
759        assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
760        assert_eq!(
761            alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
762            Alignment::Aligned
763        );
764        assert_eq!(
765            alignment("1.2.10-rc.1+build", "1.2.10"),
766            Alignment::BinaryNewer
767        );
768    }
769}