Skip to main content

release_kit/setup/
proof.rs

1//! The committed setup proof: what the last complete observation proved.
2//!
3//! Four records answer four different questions, and none substitutes for
4//! another. The target configuration is the desired state, the landing
5//! record is what landed, the host journal is what one run did on one
6//! machine, and `rk setup check` is what the forge answers now. This file
7//! is the fifth: a portable, non-secret statement that a run able to see
8//! every step saw each one hold, against a named setup contract.
9//!
10//! The record never becomes current truth. A reader judges whether it
11//! still describes this target's contract, and a live check that reads
12//! the forge still decides what holds now.
13
14use std::collections::BTreeMap;
15
16use camino::Utf8Path;
17use serde::{Deserialize, Serialize};
18
19use super::context::Ctx;
20use super::report::{Observed, Report, stance};
21use super::steps::STEPS;
22use crate::digest::Digest;
23
24/// Where the proof lives, relative to the target.
25pub const PROOF_PATH: &str = ".release-kit/setup-proof.json";
26
27/// The record's schema.
28pub const SCHEMA: &str = "rk.setup-proof/1";
29
30/// The schema family, so a newer record reads as newer rather than foreign.
31const SCHEMA_FAMILY: &str = "rk.setup-proof/";
32
33/// The setup contract a proof is judged against: the answers that decide
34/// which steps run and what each one asserts.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Subject {
38    /// The resolved target answers, by name, each in its canonical text.
39    pub target: BTreeMap<String, String>,
40    /// Every step's contract, in step-table order.
41    pub steps: Vec<SubjectStep>,
42}
43
44/// One step's contract.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct SubjectStep {
48    /// The step.
49    pub name: String,
50    /// What the step proves.
51    pub proves: String,
52    /// The steps that must hold before this one applies.
53    pub prerequisites: Vec<String>,
54    /// Where the step stands at this target.
55    pub stance: String,
56    /// Why, where it does not apply or the target excludes it.
57    #[serde(default, skip_serializing_if = "String::is_empty")]
58    pub reason: String,
59}
60
61/// One step's normalized result.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct ProvenStep {
65    /// The step.
66    pub name: String,
67    /// `satisfied`, `satisfied-with-limitation`, `skipped`, `excluded`,
68    /// `not-applicable`, or `redundant`.
69    pub state: String,
70    /// The stable limitation, where the forge enforces less than the step
71    /// claims.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub limitation: Option<String>,
74}
75
76/// The committed record.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct Proof {
80    /// Always [`SCHEMA`].
81    pub schema: String,
82    /// The binary that ran the observation: provenance, not a judgment.
83    pub rk_version: String,
84    /// When the observation completed, UTC.
85    pub verified_at: String,
86    /// The contract the observation proved.
87    pub subject: Subject,
88    /// The SHA-256 of the subject's canonical bytes.
89    pub subject_digest: String,
90    /// Every step's normalized result, in step-table order.
91    pub steps: Vec<ProvenStep>,
92}
93
94/// Where a committed proof stands against this target now.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum Standing {
97    /// No proof is committed.
98    Absent,
99    /// The proof describes this target's current contract.
100    Compatible(Proof),
101    /// The proof is whole, but the contract moved, by the fields named.
102    Stale {
103        /// The record.
104        proof: Proof,
105        /// Each changed field, by name.
106        differences: Vec<String>,
107    },
108    /// The file is not a proof this binary can read.
109    Invalid {
110        /// `unreadable`, `malformed`, `unsupported-schema`,
111        /// `future-schema`, or `digest-mismatch`.
112        reason: &'static str,
113        /// What was found, one line.
114        detail: String,
115    },
116}
117
118impl Standing {
119    /// The word a report leads with.
120    #[must_use]
121    pub const fn word(&self) -> &'static str {
122        match self {
123            Self::Absent => "absent",
124            Self::Compatible(_) => "compatible",
125            Self::Stale { .. } => "stale",
126            Self::Invalid { .. } => "invalid",
127        }
128    }
129}
130
131/// The contract this target states now.
132#[must_use]
133pub fn subject(ctx: &Ctx) -> Subject {
134    let steps = STEPS
135        .iter()
136        .map(|step| {
137            let stance = stance(ctx, step);
138            SubjectStep {
139                name: step.name.to_owned(),
140                proves: step.proves.to_owned(),
141                prerequisites: step.prereqs.iter().map(|&name| name.to_owned()).collect(),
142                stance: stance.word().to_owned(),
143                reason: stance.detail(),
144            }
145        })
146        .collect();
147    Subject {
148        target: ctx.proof_fields(),
149        steps,
150    }
151}
152
153/// The subject's digest over its canonical bytes: compact JSON, whose maps
154/// are ordered by key and whose lists keep step-table order.
155#[must_use]
156pub fn digest(subject: &Subject) -> String {
157    let bytes = serde_json::to_vec(subject).unwrap_or_default();
158    Digest::of(&bytes).to_string()
159}
160
161/// A proof of `report`, or `None` where the report is not a complete
162/// observation.
163///
164/// Only a normalized word and a stable limitation enter the record per
165/// step: a forge's own answer, a process's output, and every local
166/// coordinate stay out.
167///
168/// SATISFIES setup-proof:a-checkpoint-records-only-a-complete-observation
169/// SATISFIES setup-proof:the-proof-carries-no-secret-or-machine-coordinate
170#[must_use]
171pub fn of(ctx: &Ctx, report: &Report, verified_at: String) -> Option<Proof> {
172    if !report.checkpointable() {
173        return None;
174    }
175    let steps = report
176        .rows
177        .iter()
178        .map(|row| {
179            let (state, limitation) = match &row.observed {
180                None => (row.stance.word().to_owned(), None),
181                Some(Observed::Satisfied { limitation }) => (
182                    Observed::Satisfied {
183                        limitation: limitation.clone(),
184                    }
185                    .wire()
186                    .to_owned(),
187                    limitation.clone(),
188                ),
189                Some(other) => (other.wire().to_owned(), None),
190            };
191            ProvenStep {
192                name: row.name.to_owned(),
193                state,
194                limitation,
195            }
196        })
197        .collect();
198    let subject = subject(ctx);
199    Some(Proof {
200        schema: SCHEMA.to_owned(),
201        rk_version: env!("CARGO_PKG_VERSION").to_owned(),
202        verified_at,
203        subject_digest: digest(&subject),
204        subject,
205        steps,
206    })
207}
208
209/// The record's bytes: pretty JSON with one trailing newline.
210#[must_use]
211pub fn render(proof: &Proof) -> String {
212    let mut text = serde_json::to_string_pretty(proof).unwrap_or_default();
213    text.push('\n');
214    text
215}
216
217/// Write the proof in one rename, so an interrupted write leaves the
218/// previous record whole.
219///
220/// # Errors
221///
222/// The underlying I/O failure.
223pub fn write(target: &Utf8Path, proof: &Proof) -> std::io::Result<()> {
224    let path = target.join(PROOF_PATH);
225    if let Some(parent) = path.parent() {
226        std::fs::create_dir_all(parent)?;
227    }
228    crate::atomic::write(path.as_std_path(), render(proof).as_bytes())
229}
230
231/// Read the committed proof and judge it against `current`, offline.
232///
233/// SATISFIES setup-proof:the-status-reads-the-proof-offline
234#[must_use]
235pub fn judge(target: &Utf8Path, current: &Subject) -> Standing {
236    let path = target.join(PROOF_PATH);
237    let text = match std::fs::read_to_string(&path) {
238        Ok(text) => text,
239        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Standing::Absent,
240        Err(err) => {
241            return Standing::Invalid {
242                reason: "unreadable",
243                detail: format!("{PROOF_PATH} is unreadable: {err}"),
244            };
245        }
246    };
247    let proof = match parse(&text) {
248        Ok(proof) => proof,
249        Err((reason, detail)) => return Standing::Invalid { reason, detail },
250    };
251    let differences = differences(&proof.subject, current);
252    if differences.is_empty() {
253        Standing::Compatible(proof)
254    } else {
255        Standing::Stale { proof, differences }
256    }
257}
258
259/// Parse and verify one record: its schema first, then its shape, then
260/// that its subject is the one its digest names.
261fn parse(text: &str) -> Result<Proof, (&'static str, String)> {
262    /// The one field read before the schema is known.
263    #[derive(Deserialize)]
264    struct Envelope {
265        schema: String,
266    }
267    let malformed = |err: serde_json::Error| {
268        (
269            "malformed",
270            format!("{PROOF_PATH} is not a setup proof: {err}"),
271        )
272    };
273    let envelope: Envelope = serde_json::from_str(text).map_err(malformed)?;
274    if envelope.schema != SCHEMA {
275        let newer = envelope
276            .schema
277            .strip_prefix(SCHEMA_FAMILY)
278            .and_then(|version| version.parse::<u32>().ok())
279            .is_some_and(|version| version > 1);
280        return Err(if newer {
281            (
282                "future-schema",
283                format!(
284                    "{PROOF_PATH} is {}, newer than the {SCHEMA} this binary reads; a newer rk reads it",
285                    envelope.schema
286                ),
287            )
288        } else {
289            (
290                "unsupported-schema",
291                format!(
292                    "{PROOF_PATH} declares {}, and this binary reads {SCHEMA}",
293                    envelope.schema
294                ),
295            )
296        });
297    }
298    let proof: Proof = serde_json::from_str(text).map_err(malformed)?;
299    if digest(&proof.subject) != proof.subject_digest {
300        return Err((
301            "digest-mismatch",
302            format!(
303                "{PROOF_PATH} names a subject digest its own subject does not produce, so the record was edited after it was written"
304            ),
305        ));
306    }
307    Ok(proof)
308}
309
310/// Every field where the recorded contract and the current one differ,
311/// by name: `target.<answer>`, `step <name> <part>`, or a step that one
312/// side lacks.
313#[must_use]
314pub fn differences(recorded: &Subject, current: &Subject) -> Vec<String> {
315    let mut found = Vec::new();
316    let keys: std::collections::BTreeSet<&String> = recorded
317        .target
318        .keys()
319        .chain(current.target.keys())
320        .collect();
321    for key in keys {
322        if recorded.target.get(key) != current.target.get(key) {
323            found.push(format!("target.{key}"));
324        }
325    }
326    for step in &recorded.steps {
327        match current.steps.iter().find(|now| now.name == step.name) {
328            None => found.push(format!("step {} is no longer in the step table", step.name)),
329            Some(now) => {
330                for (part, changed) in [
331                    ("proves", step.proves != now.proves),
332                    ("prerequisites", step.prerequisites != now.prerequisites),
333                    (
334                        "stance",
335                        step.stance != now.stance || step.reason != now.reason,
336                    ),
337                ] {
338                    if changed {
339                        found.push(format!("step {} {part}", step.name));
340                    }
341                }
342            }
343        }
344    }
345    for now in &current.steps {
346        if !recorded.steps.iter().any(|step| step.name == now.name) {
347            found.push(format!("step {} is new", now.name));
348        }
349    }
350    found
351}
352
353/// The `rk.setup-status/1` document.
354#[derive(Debug, Serialize)]
355pub struct StatusDocument {
356    /// Always `rk.setup-status/1`.
357    pub schema: &'static str,
358    /// `absent`, `compatible`, `stale`, or `invalid`.
359    pub state: &'static str,
360    /// The record's provenance, where one was read.
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub checkpoint: Option<Checkpoint>,
363    /// Each changed contract field, where the proof is stale.
364    #[serde(skip_serializing_if = "Vec::is_empty")]
365    pub differences: Vec<String>,
366    /// Each step's recorded result, where one was read.
367    #[serde(skip_serializing_if = "Vec::is_empty")]
368    pub steps: Vec<ProvenStep>,
369    /// Why the file is not readable as a proof.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub invalid: Option<InvalidProof>,
372}
373
374/// A record's provenance.
375#[derive(Debug, Serialize)]
376pub struct Checkpoint {
377    /// The binary that proved it.
378    pub rk_version: String,
379    /// When.
380    pub verified_at: String,
381    /// The subject it proved.
382    pub subject_digest: String,
383}
384
385/// Why a file is not a readable proof.
386#[derive(Debug, Serialize)]
387pub struct InvalidProof {
388    /// The closed reason word.
389    pub reason: &'static str,
390    /// What was found.
391    pub detail: String,
392}
393
394impl StatusDocument {
395    /// The document for one standing.
396    #[must_use]
397    pub fn of(standing: &Standing) -> Self {
398        let mut document = Self {
399            schema: "rk.setup-status/1",
400            state: standing.word(),
401            checkpoint: None,
402            differences: Vec::new(),
403            steps: Vec::new(),
404            invalid: None,
405        };
406        let proof = match standing {
407            Standing::Absent => None,
408            Standing::Compatible(proof) => Some(proof),
409            Standing::Stale { proof, differences } => {
410                document.differences.clone_from(differences);
411                Some(proof)
412            }
413            Standing::Invalid { reason, detail } => {
414                document.invalid = Some(InvalidProof {
415                    reason,
416                    detail: detail.clone(),
417                });
418                None
419            }
420        };
421        if let Some(proof) = proof {
422            document.checkpoint = Some(Checkpoint {
423                rk_version: proof.rk_version.clone(),
424                verified_at: proof.verified_at.clone(),
425                subject_digest: proof.subject_digest.clone(),
426            });
427            document.steps.clone_from(&proof.steps);
428        }
429        document
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    fn sample_subject() -> Subject {
438        Subject {
439            target: BTreeMap::from([
440                ("forge".to_owned(), "github".to_owned()),
441                ("repo".to_owned(), "acme/widget".to_owned()),
442            ]),
443            steps: vec![SubjectStep {
444                name: "default-branch".to_owned(),
445                proves: "the trunk is the default branch".to_owned(),
446                prerequisites: Vec::new(),
447                stance: "applicable".to_owned(),
448                reason: String::new(),
449            }],
450        }
451    }
452
453    fn sample() -> Proof {
454        let subject = sample_subject();
455        Proof {
456            schema: SCHEMA.to_owned(),
457            rk_version: "0.0.0".to_owned(),
458            verified_at: "2026-09-27T00:00:00Z".to_owned(),
459            subject_digest: digest(&subject),
460            subject,
461            steps: vec![
462                ProvenStep {
463                    name: "default-branch".to_owned(),
464                    state: "satisfied".to_owned(),
465                    limitation: None,
466                },
467                ProvenStep {
468                    name: "auto-merge".to_owned(),
469                    state: "satisfied-with-limitation".to_owned(),
470                    limitation: Some("weaker".to_owned()),
471                },
472            ],
473        }
474    }
475
476    /// The record's shape, held by snapshot: a renamed field is a schema
477    /// bump, never a silent parser break.
478    ///
479    /// SATISFIES distribution:machine-output-declares-its-schema
480    #[test]
481    fn the_setup_proof_shape_is_held() {
482        let text = serde_json::to_string(&sample()).expect("serializes");
483        assert_eq!(
484            text,
485            format!(
486                r#"{{"schema":"rk.setup-proof/1","rk_version":"0.0.0","verified_at":"2026-09-27T00:00:00Z","subject":{{"target":{{"forge":"github","repo":"acme/widget"}},"steps":[{{"name":"default-branch","proves":"the trunk is the default branch","prerequisites":[],"stance":"applicable"}}]}},"subject_digest":"{}","steps":[{{"name":"default-branch","state":"satisfied"}},{{"name":"auto-merge","state":"satisfied-with-limitation","limitation":"weaker"}}]}}"#,
487                digest(&sample_subject())
488            )
489        );
490    }
491
492    /// The status document's shape, held by snapshot in each state.
493    ///
494    /// SATISFIES distribution:machine-output-declares-its-schema
495    #[test]
496    fn the_setup_status_shape_is_held() {
497        let absent = serde_json::to_string(&StatusDocument::of(&Standing::Absent)).expect("ok");
498        assert_eq!(absent, r#"{"schema":"rk.setup-status/1","state":"absent"}"#);
499        let stale = serde_json::to_string(&StatusDocument::of(&Standing::Stale {
500            proof: sample(),
501            differences: vec!["target.repo".to_owned()],
502        }))
503        .expect("ok");
504        assert_eq!(
505            stale,
506            format!(
507                r#"{{"schema":"rk.setup-status/1","state":"stale","checkpoint":{{"rk_version":"0.0.0","verified_at":"2026-09-27T00:00:00Z","subject_digest":"{}"}},"differences":["target.repo"],"steps":[{{"name":"default-branch","state":"satisfied"}},{{"name":"auto-merge","state":"satisfied-with-limitation","limitation":"weaker"}}]}}"#,
508                digest(&sample_subject())
509            )
510        );
511        let invalid = serde_json::to_string(&StatusDocument::of(&Standing::Invalid {
512            reason: "malformed",
513            detail: "not json".to_owned(),
514        }))
515        .expect("ok");
516        assert_eq!(
517            invalid,
518            r#"{"schema":"rk.setup-status/1","state":"invalid","invalid":{"reason":"malformed","detail":"not json"}}"#
519        );
520    }
521
522    /// Each way a file fails to be a proof reads as its own reason.
523    #[test]
524    fn each_unreadable_proof_names_its_own_reason() {
525        let reason = |text: &str| match parse(text) {
526            Err((reason, _)) => reason,
527            Ok(proof) => panic!("{proof:?}"),
528        };
529        assert_eq!(reason("not json"), "malformed");
530        assert_eq!(reason(r#"{"schema":"rk.setup-proof/1"}"#), "malformed");
531        assert_eq!(reason(r#"{"schema":"rk.setup-proof/2"}"#), "future-schema");
532        assert_eq!(reason(r#"{"schema":"rk.status/12"}"#), "unsupported-schema");
533        let mut edited = sample();
534        edited
535            .subject
536            .target
537            .insert("repo".into(), "acme/other".into());
538        assert_eq!(reason(&render(&edited)), "digest-mismatch");
539        assert_eq!(parse(&render(&sample())), Ok(sample()));
540    }
541
542    /// A changed contract names what changed; an equal one names nothing.
543    #[test]
544    fn a_changed_contract_names_each_field() {
545        let recorded = sample_subject();
546        assert!(differences(&recorded, &recorded).is_empty());
547        let mut current = recorded.clone();
548        current.target.insert("repo".into(), "acme/other".into());
549        current.steps[0].proves = "something else".into();
550        current.steps[0].stance = "excluded".into();
551        assert_eq!(
552            differences(&recorded, &current),
553            [
554                "target.repo",
555                "step default-branch proves",
556                "step default-branch stance"
557            ]
558        );
559        current.steps.clear();
560        assert!(
561            differences(&recorded, &current)
562                .contains(&"step default-branch is no longer in the step table".to_owned())
563        );
564    }
565}