Skip to main content

release_kit/commands/
status.rs

1//! `rk status`: a target describes itself from its own disk.
2//!
3//! Read-only and offline: the receipt supplies what landed, this binary's
4//! projection supplies what an upgrade would offer, the embedded registry
5//! supplies the pin comparison, and no network is ever touched: a fetch
6//! is a way for a status command to hang, fail on a network it should not
7//! need, or leak a repository's existence. Status compares the current
8//! target with this binary's projection and the receipt and nothing else;
9//! it reads no stage and resolves no other release. Plain `rk status`
10//! reports and exits 0 for every reportable state, drift and no-landing
11//! included; `--check` computes the identical report and changes only
12//! the final judgment, the one sanctioned bare exit 1.
13//!
14//! SATISFIES landing:status-judges-only-under-check
15
16use serde::Serialize;
17
18use crate::cli::status::StatusArgs;
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::digest::Digest;
21use crate::error::RkError;
22use crate::landing::invariants::{self, InvariantFailure};
23use crate::landing::manifest::{self, Alignment, Manifest};
24use crate::landing::{self, Kind};
25use crate::output::Output;
26use crate::projection::{Candidate, Projection, ProjectionInput, TargetEvidence};
27use crate::release::EmbeddedReleaseSource;
28use crate::{embedded, registry};
29
30/// The one seam name every front carries until the seam path goes: the
31/// status verb reads the target and this binary's projection alone.
32#[allow(dead_code, reason = "the seam path is deleted in a later phase")]
33const SOURCE: EmbeddedReleaseSource = EmbeddedReleaseSource;
34
35/// Drift counts by owned kind; `state` files are never compared.
36#[derive(Debug, Serialize)]
37struct Drift {
38    /// Edits to files release-kit owns — the violation class.
39    rendered: usize,
40    /// Edits to files the target owns — expected and informational.
41    seeded: usize,
42}
43
44/// One recorded pin that is behind this binary's registry.
45#[derive(Debug, Serialize)]
46struct StalePin {
47    /// The tool's registry name.
48    tool: String,
49    /// The version the landing recorded.
50    landed: String,
51    /// The version this binary's registry pins.
52    available: String,
53}
54
55/// Configuration is informational, independent of every drift comparison.
56#[derive(Debug, serde::Serialize)]
57struct ConfigState {
58    state: &'static str,
59    pending: Vec<String>,
60}
61
62fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
63    let pending = config
64        .zip(record)
65        .map_or_else(Vec::new, |(config, record)| {
66            crate::config::pending(config, record)
67        });
68    ConfigState {
69        state: if config.is_none() {
70            "absent"
71        } else if pending.is_empty() {
72            "aligned"
73        } else {
74            "pending"
75        },
76        pending,
77    }
78}
79
80/// The machine form of a status report.
81#[derive(Debug, Serialize)]
82struct Report {
83    /// The shape version of this document.
84    schema: &'static str,
85    /// Whether a landing record exists; every other field needs one.
86    landed: bool,
87    config: ConfigState,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    tech: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    forge: Option<String>,
92    /// The recorded working-copy mode.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    workflow: Option<&'static str>,
95    /// The recorded release style; absent on a record predating it.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    style: Option<&'static str>,
98    /// Whether the landing carries the Nix capability; a record predating
99    /// the parameter reads as opt-out.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    nix: Option<bool>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    rk_version: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    binary_version: Option<&'static str>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    alignment: Option<Alignment>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    drift: Option<Drift>,
110    /// Recorded destinations absent from the disk.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    missing: Option<Vec<String>>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    stale_pins: Option<Vec<StalePin>>,
115    /// Unresolved judgment sentinels across the landed files.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    sentinels: Option<usize>,
118    /// Record-set disagreements between the recorded parameters'
119    /// projection and the recorded destinations — its own count, because
120    /// no file was edited and the kind counts must stay honest.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    record_drift: Option<usize>,
123    /// Invariants a landed file's effective configuration violates —
124    /// judged, never rewritten, because the file stays the target's.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    invariant_failures: Option<Vec<InvariantFailure>>,
127    /// How many destinations an upgrade would change: the count this
128    /// binary's payload projects under the recorded parameters against
129    /// what the record names. Zero means nothing to take, whatever the two
130    /// versions say. Absent on a landed target means this binary carries
131    /// no payload for the recorded pair and cannot answer.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pending: Option<usize>,
134    /// Present only under `--check`: what the judgment failed on.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    violations: Option<Vec<String>>,
137}
138
139fn report_absent(
140    out: Output,
141    args: &StatusArgs,
142    config: Option<&crate::config::Config>,
143) -> Result<(), RkError> {
144    out.result_line(format!("config: {}", config_state(config, None).state));
145    out.result_line(format!("no landing at {}", args.target));
146    out.next(&[
147        format!(
148            "rk init --tech <tech> --target {} lands the workflow",
149            args.target
150        ),
151        format!(
152            "rk adopt --target {} records a landing made before the receipt existed",
153            args.target
154        ),
155        format!(
156            "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
157            args.target
158        ),
159    ]);
160    out.emit(&Report {
161        schema: "rk.status/9",
162        landed: false,
163        config: config_state(config, None),
164        tech: None,
165        forge: None,
166        workflow: None,
167        style: None,
168        nix: None,
169        rk_version: None,
170        binary_version: None,
171        alignment: None,
172        drift: None,
173        missing: None,
174        stale_pins: None,
175        sentinels: None,
176        record_drift: None,
177        invariant_failures: None,
178        pending: None,
179        violations: args.check.then(|| vec!["no landing".to_owned()]),
180    })?;
181    if args.check {
182        return Err(RkError::check_failed(
183            Diagnostic::new(
184                Reason::StateDrift,
185                format!("no landing at {}, and --check requires one", args.target),
186            )
187            .expected("a target carrying .release-kit/manifest.json")
188            .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
189        ));
190    }
191    Ok(())
192}
193
194/// What one pass over the record and the disk observed.
195struct Observed {
196    drift_rendered: Vec<String>,
197    drift_seeded: Vec<String>,
198    /// Recorded block destinations whose recorded digest the record's own
199    /// parameters do not reproduce: the record was edited, not the file.
200    parameter_drift: Vec<String>,
201    /// Set differences between what the recorded parameters project —
202    /// the withhold judgment applied — and the destinations the record
203    /// names: a record whose parameters and file list disagree, whichever
204    /// of the two was edited or outgrown.
205    record_drift: Vec<String>,
206    missing: Vec<String>,
207    stale: Vec<StalePin>,
208    sentinels: Vec<(String, usize, String)>,
209    invariants: Vec<InvariantFailure>,
210    /// The destinations an upgrade would change, or `None` where this
211    /// binary carries no projection for the recorded pair and so cannot
212    /// say.
213    pending: Option<Vec<String>>,
214}
215
216/// Report the target's landing.
217///
218/// # Errors
219///
220/// Returns [`RkError::Missing`] for a target that is not a directory, the
221/// record's own failure taxonomy for an unreadable or unknown record, and
222/// [`RkError::CheckFailed`] under `--check` when the report holds a
223/// violation.
224pub fn run(args: &StatusArgs) -> Result<(), RkError> {
225    let out = Output::new(args.json);
226    if !args.target.is_dir() {
227        return Err(RkError::missing(
228            Diagnostic::new(
229                Reason::TargetNotFound,
230                format!("target {} is not a directory", args.target),
231            )
232            .expected("an existing repository to report on"),
233        ));
234    }
235    let config = crate::config::load(args.target.as_std_path())?;
236    let Some(manifest) = manifest::load(&args.target)? else {
237        return report_absent(out, args, config.as_ref());
238    };
239
240    let config = config_state(config.as_ref(), Some(&manifest));
241    out.result_line(format!("config: {}", config.state));
242    for key in &config.pending {
243        out.result_line(format!(
244            "config pending: {key}; rk upgrade --apply takes it up"
245        ));
246    }
247    let observed = observe(args, &manifest)?;
248    let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
249    render_human(out, args, &manifest, alignment, &observed);
250
251    let violations = violations_of(&observed);
252    out.emit(&Report {
253        schema: "rk.status/9",
254        landed: true,
255        config,
256        tech: Some(manifest.tech),
257        forge: Some(manifest.forge),
258        workflow: Some(manifest.parameters.workflow.as_str()),
259        style: manifest.parameters.style.map(manifest::Style::as_str),
260        nix: Some(manifest.parameters.nix),
261        rk_version: Some(manifest.rk_version),
262        binary_version: Some(env!("CARGO_PKG_VERSION")),
263        alignment: Some(alignment),
264        drift: Some(Drift {
265            rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
266            seeded: observed.drift_seeded.len(),
267        }),
268        record_drift: Some(observed.record_drift.len()),
269        missing: Some(observed.missing.clone()),
270        stale_pins: Some(observed.stale),
271        sentinels: Some(observed.sentinels.len()),
272        invariant_failures: Some(observed.invariants),
273        pending: observed.pending.as_ref().map(Vec::len),
274        violations: args.check.then(|| violations.clone()),
275    })?;
276
277    if args.check && !violations.is_empty() {
278        return Err(RkError::check_failed(
279            Diagnostic::new(
280                Reason::StateDrift,
281                format!(
282                    "the landing is not clean: {} violation{}",
283                    violations.len(),
284                    if violations.len() == 1 { "" } else { "s" }
285                ),
286            )
287            .expected(
288                "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
289            ),
290        ));
291    }
292    Ok(())
293}
294
295/// The check-mode violation lines: rendered drift, missing recorded
296/// files, unresolved sentinels, and invariant failures — the closed set
297/// `landing:status-judges-only-under-check` names.
298fn violations_of(observed: &Observed) -> Vec<String> {
299    observed
300        .drift_rendered
301        .iter()
302        .map(|path| format!("rendered drift: {path}"))
303        .chain(
304            observed
305                .parameter_drift
306                .iter()
307                .map(|path| format!("parameter drift: {path}")),
308        )
309        .chain(
310            observed
311                .record_drift
312                .iter()
313                .map(|reason| format!("record drift: {reason}")),
314        )
315        .chain(
316            observed
317                .missing
318                .iter()
319                .map(|path| format!("missing: {path}")),
320        )
321        .chain(
322            observed
323                .sentinels
324                .iter()
325                .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
326        )
327        .chain(
328            observed
329                .invariants
330                .iter()
331                .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
332        )
333        .collect()
334}
335
336/// One pass over the record and the disk: drift, missing files, stale
337/// pins, and sentinels.
338fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
339    let mut observed = Observed {
340        drift_rendered: Vec::new(),
341        drift_seeded: Vec::new(),
342        parameter_drift: Vec::new(),
343        record_drift: Vec::new(),
344        missing: Vec::new(),
345        stale: Vec::new(),
346        sentinels: Vec::new(),
347        invariants: Vec::new(),
348        pending: None,
349    };
350    // One projection serves every reader below, because each asks what
351    // this binary makes of the recorded parameters at this target. A pair
352    // this binary does not carry cannot be projected at all: under a
353    // receipt this binary wrote that is a defect and still fails, and
354    // under a landing from another rk it is a fact to report.
355    let aligned =
356        manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
357    let projected = match project(args, manifest) {
358        Ok(projection) => Some(projection),
359        Err(err) if aligned => return Err(err),
360        Err(_) => None,
361    };
362    for file in &manifest.files {
363        let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
364            observed.missing.push(file.destination.clone());
365            continue;
366        };
367        if Digest::of(&bytes) != file.sha256 {
368            match file.kind {
369                Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
370                Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
371                Kind::State => {}
372            }
373        }
374        observed.invariants.extend(invariants::failures(
375            &manifest.tech,
376            &manifest.forge,
377            &file.destination,
378            &bytes,
379        ));
380        let text = String::from_utf8_lossy(&bytes);
381        for (idx, line) in text.lines().enumerate() {
382            if line.contains(embedded::SENTINEL) {
383                observed.sentinels.push((
384                    file.destination.clone(),
385                    idx + 1,
386                    line.trim().to_owned(),
387                ));
388            }
389        }
390        // A marked document's markers must be well formed even when its
391        // first block matches the receipt: a duplicate hook block still
392        // executes, so an ill-formed document reads as rendered drift,
393        // never as clean.
394        let defective = projected.as_ref().is_some_and(|projection| {
395            projection
396                .collisions
397                .iter()
398                .any(|collision| collision.destination == file.destination)
399        });
400        if defective && !observed.drift_rendered.contains(&file.destination) {
401            observed.drift_rendered.push(file.destination.clone());
402        }
403    }
404    // The cross-file step: a landed file can generate the artifact the
405    // forge actually executes, and the payload ships no copy of it, so no
406    // recorded digest sees the two disagree. The pair's own rule reads
407    // both off the target's disk.
408    observed.invariants.extend(invariants::target_failures(
409        &manifest.tech,
410        &manifest.forge,
411        &args.target,
412    ));
413    if aligned && let Some(projection) = projected.as_ref() {
414        observe_parameter_drift(manifest, projection, &mut observed);
415        observe_record_set(manifest, projection, &mut observed.record_drift);
416    }
417    // What an upgrade would change, which is the only honest ground for
418    // telling an operator to run one. The recorded version says who wrote
419    // the receipt, and two releases apart can carry identical bytes for
420    // this pair, so it answers a different question and prompts nothing.
421    observed.pending = projected
422        .as_ref()
423        .map(|projection| pending_of(manifest, &projection.candidates));
424    // Stale means behind, not merely different: a landing from a newer rk
425    // can carry pins ahead of this binary's registry, and that is the
426    // alignment line's story, not a freshness complaint.
427    for (tool, landed) in &manifest.pins {
428        if let Some(available) = registry::version_of(tool)
429            && manifest::version_is_newer(&available, landed)
430        {
431            observed.stale.push(StalePin {
432                tool: tool.clone(),
433                landed: landed.clone(),
434                available,
435            });
436        }
437    }
438    Ok(observed)
439}
440
441/// The receipt-consistency step over every rendered destination.
442///
443/// Recorded digests alone cannot see a receipt edited only at its
444/// parameters, since every file still matches its own record, so every
445/// rendered candidate, whole file or region, as this projection renders
446/// it from the receipt's own parameters, is compared against the digest
447/// the receipt stores for it. Called only where this binary wrote the
448/// receipt: an older landing's files legitimately differ from this
449/// projection, which is the alignment line's story and the upgrade's job,
450/// not parameter drift. A destination already reported as rendered drift
451/// is the file's own story, not the receipt's, and is skipped too.
452fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
453    for candidate in &projection.candidates {
454        if candidate.kind != Kind::Rendered {
455            continue;
456        }
457        let Some(record) = manifest.file(&candidate.destination) else {
458            continue;
459        };
460        if observed.drift_rendered.contains(&candidate.destination)
461            || observed.missing.contains(&candidate.destination)
462        {
463            continue;
464        }
465        if Digest::of(recorded_form(candidate)) != record.sha256 {
466            observed
467                .parameter_drift
468                .push(format!("{} (parameters)", candidate.destination));
469        }
470    }
471}
472
473/// What this binary projects under the receipt's own parameters at this
474/// target, with the same withhold judgment a landing applies, so the
475/// comparison stands against what an upgrade would actually offer.
476fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
477    let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
478    Projection::compute(&ProjectionInput {
479        params: landing::Params::from_record(manifest),
480        evidence,
481    })
482}
483
484/// The bytes the receipt digests for a candidate: the whole file, or the
485/// marked region alone.
486fn recorded_form(candidate: &Candidate) -> &[u8] {
487    candidate.region.as_deref().unwrap_or(&candidate.bytes)
488}
489
490/// The destinations an upgrade would change, read off the record alone.
491///
492/// A destination the projection adds or drops changes the record either
493/// way, and a `rendered` one whose candidate digest differs from the
494/// recorded digest is rewritten. A `seeded` or `state` destination the
495/// record already names is never rewritten, so only a change of kind
496/// counts for it. Disk drift is a separate story, told by its own lines:
497/// an edited file is the target's doing, not a newer payload's.
498fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
499    let mut pending = Vec::new();
500    for entry in projected {
501        let changed = manifest.file(&entry.destination).is_none_or(|record| {
502            record.kind != entry.kind
503                || (entry.kind == Kind::Rendered
504                    && record.sha256 != Digest::of(recorded_form(entry)))
505        });
506        if changed {
507            pending.push(entry.destination.clone());
508        }
509    }
510    for file in &manifest.files {
511        if !projected
512            .iter()
513            .any(|entry| entry.destination == file.destination)
514        {
515            pending.push(file.destination.clone());
516        }
517    }
518    pending.sort();
519    pending.dedup();
520    pending
521}
522
523/// The receipt-set consistency step: the recorded digests judge each
524/// named file, and the region re-render judges the region records, but
525/// neither can see a receipt whose parameters and file list disagree, a
526/// nix flag flipped in the receipt with no file landed, or a
527/// once-withheld capability whose target grew into the supported shape.
528/// So the projection is computed from the receipt's own parameters, the
529/// same withhold judgment applied, and the two destination sets compared
530/// both ways. A destination the projection withholds at this target is
531/// absent because withheld, which is not drift. Called only where this
532/// binary wrote the receipt: an older landing's set legitimately differs,
533/// and that is the alignment line's story.
534fn observe_record_set(
535    manifest: &Manifest,
536    projection: &Projection,
537    record_drift: &mut Vec<String>,
538) {
539    for entry in &projection.candidates {
540        if manifest.file(&entry.destination).is_none() {
541            record_drift.push(format!(
542                "the recorded parameters project {}, which the receipt does not name",
543                entry.destination
544            ));
545        }
546    }
547    for file in &manifest.files {
548        let produced = projection
549            .candidates
550            .iter()
551            .any(|entry| entry.destination == file.destination)
552            || projection
553                .omissions
554                .iter()
555                .any(|omission| omission.destination == file.destination);
556        if !produced {
557            record_drift.push(format!(
558                "the receipt names {}, which the recorded parameters do not project",
559                file.destination
560            ));
561        }
562    }
563}
564
565/// The human lines, identical with and without `--check`.
566fn render_human(
567    out: Output,
568    args: &StatusArgs,
569    manifest: &Manifest,
570    alignment: Alignment,
571    observed: &Observed,
572) {
573    out.result_line(format!(
574        "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
575        manifest.rk_version,
576        manifest.tech,
577        manifest.forge,
578        manifest.parameters.workflow.as_str(),
579        manifest
580            .parameters
581            .style
582            .map_or("unrecorded", manifest::Style::as_str),
583        if manifest.parameters.nix { ", nix" } else { "" },
584        args.target
585    ));
586    if alignment == Alignment::TargetNewer {
587        out.result_line(format!(
588            "binary {} is older than this landing; install the matching rk",
589            env!("CARGO_PKG_VERSION")
590        ));
591    }
592    match observed.pending.as_deref() {
593        None => out.result_line(format!(
594            "this binary carries no {}/{} projection, so what an upgrade would change is unknown",
595            manifest.tech, manifest.forge
596        )),
597        Some(paths) => {
598            for path in paths {
599                out.result_line(format!("PENDING {path} (this binary would change it)"));
600            }
601        }
602    }
603    for path in &observed.drift_rendered {
604        out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
605    }
606    for path in &observed.parameter_drift {
607        out.result_line(format!(
608            "DRIFT {path}: the recorded parameters do not render the recorded bytes"
609        ));
610    }
611    for reason in &observed.record_drift {
612        out.result_line(format!("DRIFT record: {reason}"));
613    }
614    for path in &observed.drift_seeded {
615        out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
616    }
617    for path in &observed.missing {
618        out.result_line(format!("MISSING {path}"));
619    }
620    for pin in &observed.stale {
621        out.result_line(format!(
622            "STALE {} {} landed, {} in this binary",
623            pin.tool, pin.landed, pin.available
624        ));
625    }
626    for (path, line, text) in &observed.sentinels {
627        out.result_line(format!("SENTINEL {path}:{line}: {text}"));
628    }
629    for failure in &observed.invariants {
630        out.result_line(format!(
631            "INVARIANT {} ({}): {}",
632            failure.destination, failure.code, failure.reason
633        ));
634    }
635    let mut next = Vec::new();
636    for failure in &observed.invariants {
637        next.push(format!("{}: {}", failure.destination, failure.remediation));
638    }
639    if !observed.record_drift.is_empty() {
640        next.push(format!(
641            "rk upgrade --target {} rewrites the receipt from its parameters",
642            args.target
643        ));
644    }
645    if observed
646        .pending
647        .as_deref()
648        .is_none_or(|paths| !paths.is_empty())
649    {
650        next.push(format!(
651            "rk upgrade --target {} takes this landing to {}",
652            args.target,
653            env!("CARGO_PKG_VERSION")
654        ));
655    }
656    next.push(format!(
657        "rk status --check --target {} exits 1 on a violation",
658        args.target
659    ));
660    out.next(&next);
661}
662
663#[cfg(test)]
664mod tests {
665    use super::{Drift, InvariantFailure, Report, StalePin};
666
667    /// The complete `rk.status/9` shape, held by snapshot in both the
668    /// landed and absent forms.
669    #[test]
670    fn the_status_report_schema_snapshot_holds() {
671        let landed = Report {
672            schema: "rk.status/9",
673            landed: true,
674            config: super::ConfigState {
675                state: "pending",
676                pending: vec!["landing.style".into()],
677            },
678            tech: Some("rust".into()),
679            forge: Some("github".into()),
680            workflow: Some("worktree"),
681            style: Some("trunk"),
682            nix: Some(true),
683            rk_version: Some("0.1.0".into()),
684            binary_version: Some("0.2.0"),
685            alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
686            drift: Some(Drift {
687                rendered: 0,
688                seeded: 1,
689            }),
690            missing: Some(vec![]),
691            stale_pins: Some(vec![StalePin {
692                tool: "release-plz".into(),
693                landed: "0.3.160".into(),
694                available: "0.3.170".into(),
695            }]),
696            sentinels: Some(1),
697            record_drift: Some(0),
698            invariant_failures: Some(vec![InvariantFailure {
699                code: "attestations-disabled",
700                destination: "dist-workspace.toml".into(),
701                reason: "github-attestations is not effectively true".into(),
702                remediation: "set github-attestations = true in [dist]",
703            }]),
704            pending: Some(2),
705            violations: None,
706        };
707        assert_eq!(
708            serde_json::to_string(&landed).expect("a report serializes"),
709            r#"{"schema":"rk.status/9","landed":true,"config":{"state":"pending","pending":["landing.style"]},"tech":"rust","forge":"github","workflow":"worktree","style":"trunk","nix":true,"rk_version":"0.1.0","binary_version":"0.2.0","alignment":"binary-newer","drift":{"rendered":0,"seeded":1},"missing":[],"stale_pins":[{"tool":"release-plz","landed":"0.3.160","available":"0.3.170"}],"sentinels":1,"record_drift":0,"invariant_failures":[{"code":"attestations-disabled","destination":"dist-workspace.toml","reason":"github-attestations is not effectively true","remediation":"set github-attestations = true in [dist]"}],"pending":2}"#
710        );
711        let absent = Report {
712            landed: false,
713            config: super::ConfigState {
714                state: "absent",
715                pending: vec![],
716            },
717            tech: None,
718            forge: None,
719            workflow: None,
720            style: None,
721            nix: None,
722            rk_version: None,
723            binary_version: None,
724            alignment: None,
725            drift: None,
726            missing: None,
727            stale_pins: None,
728            sentinels: None,
729            record_drift: None,
730            invariant_failures: None,
731            pending: None,
732            violations: None,
733            ..landed
734        };
735        assert_eq!(
736            serde_json::to_string(&absent).expect("a report serializes"),
737            r#"{"schema":"rk.status/9","landed":false,"config":{"state":"absent","pending":[]}}"#,
738            "an absent landing reports one field a caller can branch on"
739        );
740    }
741}