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_for(ctx).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/// The committed proof judged against the contract the target states
232/// now, from its configuration and its record alone: no setup flag and
233/// no forge call, so `rk status` reads it where no credential is.
234///
235/// # Errors
236///
237/// The context's resolution refusals.
238///
239/// SATISFIES setup-proof:the-status-check-holds-the-proof
240pub fn offline(target: &camino::Utf8PathBuf) -> Result<Standing, crate::error::RkError> {
241    let ctx = Ctx::offline(target)?;
242    Ok(judge(&ctx.target, &subject(&ctx)))
243}
244
245/// Read the committed proof and judge it against `current`, offline.
246///
247/// SATISFIES setup-proof:the-status-reads-the-proof-offline
248#[must_use]
249pub fn judge(target: &Utf8Path, current: &Subject) -> Standing {
250    let path = target.join(PROOF_PATH);
251    let text = match std::fs::read_to_string(&path) {
252        Ok(text) => text,
253        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Standing::Absent,
254        Err(err) => {
255            return Standing::Invalid {
256                reason: "unreadable",
257                detail: format!("{PROOF_PATH} is unreadable: {err}"),
258            };
259        }
260    };
261    let proof = match parse(&text) {
262        Ok(proof) => proof,
263        Err((reason, detail)) => return Standing::Invalid { reason, detail },
264    };
265    let differences = differences(&proof.subject, current);
266    if differences.is_empty() {
267        Standing::Compatible(proof)
268    } else {
269        Standing::Stale { proof, differences }
270    }
271}
272
273/// Parse and verify one record: its schema first, then its shape, then
274/// that its subject is the one its digest names.
275fn parse(text: &str) -> Result<Proof, (&'static str, String)> {
276    /// The one field read before the schema is known.
277    #[derive(Deserialize)]
278    struct Envelope {
279        schema: String,
280    }
281    let malformed = |err: serde_json::Error| {
282        (
283            "malformed",
284            format!("{PROOF_PATH} is not a setup proof: {err}"),
285        )
286    };
287    let envelope: Envelope = serde_json::from_str(text).map_err(malformed)?;
288    if envelope.schema != SCHEMA {
289        let newer = envelope
290            .schema
291            .strip_prefix(SCHEMA_FAMILY)
292            .and_then(|version| version.parse::<u32>().ok())
293            .is_some_and(|version| version > 1);
294        return Err(if newer {
295            (
296                "future-schema",
297                format!(
298                    "{PROOF_PATH} is {}, newer than the {SCHEMA} this binary reads; a newer rk reads it",
299                    envelope.schema
300                ),
301            )
302        } else {
303            (
304                "unsupported-schema",
305                format!(
306                    "{PROOF_PATH} declares {}, and this binary reads {SCHEMA}",
307                    envelope.schema
308                ),
309            )
310        });
311    }
312    let proof: Proof = serde_json::from_str(text).map_err(malformed)?;
313    if digest(&proof.subject) != proof.subject_digest {
314        return Err((
315            "digest-mismatch",
316            format!(
317                "{PROOF_PATH} names a subject digest its own subject does not produce, so the record was edited after it was written"
318            ),
319        ));
320    }
321    Ok(proof)
322}
323
324/// Every field where the recorded contract and the current one differ,
325/// by name: `target.<answer>`, `step <name> <part>`, or a step that one
326/// side lacks.
327#[must_use]
328pub fn differences(recorded: &Subject, current: &Subject) -> Vec<String> {
329    let mut found = Vec::new();
330    let keys: std::collections::BTreeSet<&String> = recorded
331        .target
332        .keys()
333        .chain(current.target.keys())
334        .collect();
335    for key in keys {
336        if recorded.target.get(key) != current.target.get(key) {
337            found.push(format!("target.{key}"));
338        }
339    }
340    for step in &recorded.steps {
341        match current.steps.iter().find(|now| now.name == step.name) {
342            None => found.push(format!("step {} is no longer in the step table", step.name)),
343            Some(now) => {
344                for (part, changed) in [
345                    ("proves", step.proves != now.proves),
346                    ("prerequisites", step.prerequisites != now.prerequisites),
347                    (
348                        "stance",
349                        step.stance != now.stance || step.reason != now.reason,
350                    ),
351                ] {
352                    if changed {
353                        found.push(format!("step {} {part}", step.name));
354                    }
355                }
356            }
357        }
358    }
359    for now in &current.steps {
360        if !recorded.steps.iter().any(|step| step.name == now.name) {
361            found.push(format!("step {} is new", now.name));
362        }
363    }
364    found
365}
366
367/// The `rk.setup-status/1` document.
368#[derive(Debug, Serialize)]
369pub struct StatusDocument {
370    /// Always `rk.setup-status/1`.
371    pub schema: &'static str,
372    /// `absent`, `compatible`, `stale`, or `invalid`.
373    pub state: &'static str,
374    /// The record's provenance, where one was read.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub checkpoint: Option<Checkpoint>,
377    /// Each changed contract field, where the proof is stale.
378    #[serde(skip_serializing_if = "Vec::is_empty")]
379    pub differences: Vec<String>,
380    /// Each step's recorded result, where one was read.
381    #[serde(skip_serializing_if = "Vec::is_empty")]
382    pub steps: Vec<ProvenStep>,
383    /// Why the file is not readable as a proof.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub invalid: Option<InvalidProof>,
386}
387
388/// A record's provenance.
389#[derive(Debug, Serialize)]
390pub struct Checkpoint {
391    /// The binary that proved it.
392    pub rk_version: String,
393    /// When.
394    pub verified_at: String,
395    /// The subject it proved.
396    pub subject_digest: String,
397}
398
399/// Why a file is not a readable proof.
400#[derive(Debug, Serialize)]
401pub struct InvalidProof {
402    /// The closed reason word.
403    pub reason: &'static str,
404    /// What was found.
405    pub detail: String,
406}
407
408impl StatusDocument {
409    /// The document for one standing.
410    #[must_use]
411    pub fn of(standing: &Standing) -> Self {
412        let mut document = Self {
413            schema: "rk.setup-status/1",
414            state: standing.word(),
415            checkpoint: None,
416            differences: Vec::new(),
417            steps: Vec::new(),
418            invalid: None,
419        };
420        let proof = match standing {
421            Standing::Absent => None,
422            Standing::Compatible(proof) => Some(proof),
423            Standing::Stale { proof, differences } => {
424                document.differences.clone_from(differences);
425                Some(proof)
426            }
427            Standing::Invalid { reason, detail } => {
428                document.invalid = Some(InvalidProof {
429                    reason,
430                    detail: detail.clone(),
431                });
432                None
433            }
434        };
435        if let Some(proof) = proof {
436            document.checkpoint = Some(Checkpoint {
437                rk_version: proof.rk_version.clone(),
438                verified_at: proof.verified_at.clone(),
439                subject_digest: proof.subject_digest.clone(),
440            });
441            document.steps.clone_from(&proof.steps);
442        }
443        document
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn sample_subject() -> Subject {
452        Subject {
453            target: BTreeMap::from([
454                ("forge".to_owned(), "github".to_owned()),
455                ("repo".to_owned(), "acme/widget".to_owned()),
456            ]),
457            steps: vec![SubjectStep {
458                name: "default-branch".to_owned(),
459                proves: "the trunk is the default branch".to_owned(),
460                prerequisites: Vec::new(),
461                stance: "applicable".to_owned(),
462                reason: String::new(),
463            }],
464        }
465    }
466
467    fn sample() -> Proof {
468        let subject = sample_subject();
469        Proof {
470            schema: SCHEMA.to_owned(),
471            rk_version: "0.0.0".to_owned(),
472            verified_at: "2026-09-27T00:00:00Z".to_owned(),
473            subject_digest: digest(&subject),
474            subject,
475            steps: vec![
476                ProvenStep {
477                    name: "default-branch".to_owned(),
478                    state: "satisfied".to_owned(),
479                    limitation: None,
480                },
481                ProvenStep {
482                    name: "auto-merge".to_owned(),
483                    state: "satisfied-with-limitation".to_owned(),
484                    limitation: Some("weaker".to_owned()),
485                },
486            ],
487        }
488    }
489
490    /// The record's shape, held by snapshot: a renamed field is a schema
491    /// bump, never a silent parser break.
492    ///
493    /// SATISFIES distribution:machine-output-declares-its-schema
494    #[test]
495    fn the_setup_proof_shape_is_held() {
496        let text = serde_json::to_string(&sample()).expect("serializes");
497        assert_eq!(
498            text,
499            format!(
500                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"}}]}}"#,
501                digest(&sample_subject())
502            )
503        );
504    }
505
506    /// The status document's shape, held by snapshot in each state.
507    ///
508    /// SATISFIES distribution:machine-output-declares-its-schema
509    #[test]
510    fn the_setup_status_shape_is_held() {
511        let absent = serde_json::to_string(&StatusDocument::of(&Standing::Absent)).expect("ok");
512        assert_eq!(absent, r#"{"schema":"rk.setup-status/1","state":"absent"}"#);
513        let stale = serde_json::to_string(&StatusDocument::of(&Standing::Stale {
514            proof: sample(),
515            differences: vec!["target.repo".to_owned()],
516        }))
517        .expect("ok");
518        assert_eq!(
519            stale,
520            format!(
521                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"}}]}}"#,
522                digest(&sample_subject())
523            )
524        );
525        let invalid = serde_json::to_string(&StatusDocument::of(&Standing::Invalid {
526            reason: "malformed",
527            detail: "not json".to_owned(),
528        }))
529        .expect("ok");
530        assert_eq!(
531            invalid,
532            r#"{"schema":"rk.setup-status/1","state":"invalid","invalid":{"reason":"malformed","detail":"not json"}}"#
533        );
534    }
535
536    /// Each way a file fails to be a proof reads as its own reason.
537    #[test]
538    fn each_unreadable_proof_names_its_own_reason() {
539        let reason = |text: &str| match parse(text) {
540            Err((reason, _)) => reason,
541            Ok(proof) => panic!("{proof:?}"),
542        };
543        assert_eq!(reason("not json"), "malformed");
544        assert_eq!(reason(r#"{"schema":"rk.setup-proof/1"}"#), "malformed");
545        assert_eq!(reason(r#"{"schema":"rk.setup-proof/2"}"#), "future-schema");
546        assert_eq!(reason(r#"{"schema":"rk.status/12"}"#), "unsupported-schema");
547        let mut edited = sample();
548        edited
549            .subject
550            .target
551            .insert("repo".into(), "acme/other".into());
552        assert_eq!(reason(&render(&edited)), "digest-mismatch");
553        assert_eq!(parse(&render(&sample())), Ok(sample()));
554    }
555
556    /// A changed contract names what changed; an equal one names nothing.
557    #[test]
558    fn a_changed_contract_names_each_field() {
559        let recorded = sample_subject();
560        assert!(differences(&recorded, &recorded).is_empty());
561        let mut current = recorded.clone();
562        current.target.insert("repo".into(), "acme/other".into());
563        current.steps[0].proves = "something else".into();
564        current.steps[0].stance = "excluded".into();
565        assert_eq!(
566            differences(&recorded, &current),
567            [
568                "target.repo",
569                "step default-branch proves",
570                "step default-branch stance"
571            ]
572        );
573        current.steps.clear();
574        assert!(
575            differences(&recorded, &current)
576                .contains(&"step default-branch is no longer in the step table".to_owned())
577        );
578    }
579}