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