Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dry-run preview model

Principles:

Context

Intent commands originally mixed dry-run rendering seams with execution planning. Some commands compiled Vec<Step> directly for preview tests, while execution consumed separate command-specific state. That made it too easy for a dry-run preview to drift from the work a real run would perform, especially around LUKS preparation, btrfs mutations, journals, cleanup, and follow-up maintenance such as resize or balance.

The current model keeps dry-run preview and execution tied to the same typed semantic decision. Step is only the output shape used to show a preview; it is not the plan.

Decision

For migrated mutating commands, dispatch owns the read-side fences that must run under the pool lock before the planner starts: pending-operation preflight and config loading. The pending-operation preflight must run before config load so a recovery journal is never hidden behind a config parse error. The planner then owns pool state loading, live probes, accumulated preview notes, and construction of a typed work plan. This split finishes the Rust-owned pool-lock migration: the lock boundary and the config/journal reads it protects now live above plan_*(), while dry-run and real execution still share the same typed plan. The command wrapper calls the planner first. On --dry-run, it prints plan.preview() to stdout. On a real run, it passes the same plan to execute().

A successful command plan carries:

  • accumulated PreviewNotes, in the order they must render;
  • a typed WorkPlan containing the semantic choices execution needs.

preview() is the public dry-run boundary. It constructs a Preview whose steps come from work_plan.render_steps(). Notes render first, then steps. A plan struct must not cache a rendered Vec<Step> alongside its work plan.

execute() consumes the same typed WorkPlan. It must not rediscover or reinterpret semantic choices already made during planning. It may still perform execution-time validation that dry-run intentionally cannot do, such as checks that require a passphrase or a mapper that was closed during planning.

Step is output-only. It may describe risk, human text, and representative commands for dry-run rendering, but it must not become an execution source, a planning cache, or a second semantic model.

When planning accumulates notes and then fails later, use a report shape that returns both the error and the accumulated notes. The command wrapper renders those notes to stderr before returning the error, using the same preview note renderers that dry-run stdout uses. This preserves context without duplicating wording.

Output contract

The structured dry-run preview lives on stdout. Preview notes are part of that stdout preview. Real-run notes, and notes preserved on a later planning error, render to stderr through the shared preview renderers so warning and info wording stays byte-compatible across modes.

Confirmation UI

Confirmation UI is not a preview note. The interactive !params.yes block – the command summary, yes/no prompt, and go/no-go safety warnings attached to that prompt – is deliberately absent from both --dry-run and --yes output. In cli/src/remove.rs and cli/src/replace.rs, the 1-disk redundancy warning belongs to this class because it gates the operator’s final decision about an explicitly requested action, rather than reporting a discovered precondition.

For remove 2->1, dry-run still surfaces the redundancy-loss consequence as the RAID1 -> single balance step. For replace, the 1-disk warning is confirmation-only context for a pool that is non-redundant before and after; dry-run previews the replacement steps, and no redundancy-changing step exists for that warning.

Long-running side-effect-free probes that run while building a preview may emit [wait] / [ok] / [skip] status rows to stderr per Principle 13. Those rows are not part of the structured preview.

Fresh-format identity placeholder

A fresh LUKS format mints its identity per-invocation at plan time (ADR-024), so the UUID a real run will write does not yet exist when dry-run renders. Showing the minted UUID would make the preview non-reproducible – two dry-runs of the same command would differ – and misleading, since that value is discarded when dry-run returns and a later real run mints a different one.

So the two fresh-format render sites (cli/src/add.rs#AddWorkPlan::render_steps and cli/src/replace.rs#ReplaceWorkPlan::render_steps) emit a preview-only cli/src/cmd.rs#CmdRequest, CryptsetupLuksFormatPreview, whose to_argv renders a fixed --uuid '<generated-at-format-time>' placeholder (single-quoted by shell_words). The real run uses CryptsetupLuksFormat with the journaled identity. Both render through one shared cli/src/cmd.rs#luks_format_argv builder, so a future luksFormat flag appears in both at once – the “representative commands” / “Step is output-only” rules in the Decision section still hold; this is the one place the rendered command intentionally diverges from the real argv. The preview variant is never executed: cli/src/cmd.rs#RealRunner hard-errors on it via cli/src/cmd.rs#CmdRequest::is_preview_only before any spawn.

recover is excluded: cli/src/recover.rs#render_add_pool_mutation_recovery_steps also emits CryptsetupLuksFormat, but its UUID comes from the committed journal – reproducible and meaningful – so recover keeps rendering the real identity.

Scope

The typed work-plan preview model is the precedent for add, replace, remove, remove-missing, and recover.

Recover is the one deliberate exception to the read-side planner rule. When recovering an interrupted existing-pool add and the pool is not already mounted, plan_recover reconciles the validated add-targets – those present, LUKS-openable, and not yet pool members – before mount: it opens any whose mapper is closed (resolving the unlock credential once, and only then), and btrfs-scans a target only when its mapper shows a btrfs signature. All of this is gated by !dry_run (discover_add_targets_before_mount, after an already-mounted short-circuit). The preflight is non-destructive and exists for two reasons: resolving the credential in the preflight window where an interactive prompt belongs, then caching it so execute reuses it without a second prompt (single passphrase, Principle 4); and making an already-committed-but-closed target visible to the kernel before the initial mount so the mount assembles it instead of recover re-adding or re-formatting it. It is not a general license to mutate inside plan_*().

The LUKS-UUID-identity migration also gave lock a typed close set (LockCloseSet carrying ordered LockMapperClose entries in cli/src/lock.rs). Dry-run step compilation (compile_lock_steps), btrfs device scan --forget, and LockPlan::execute all read from that close set so preview and real execution share one identity classification. LockPlan::preview() derives Vec<Step> on demand from the close set rather than caching rendered steps.

Older dry-run seams in unlock and enroll may remain until those commands are intentionally migrated. Do not use their older helpers or cached step fields as precedent for commands already on the typed work-plan model.

Consequences

  • Tests about user-visible dry-run output should prefer plan_*() followed by plan.preview().render().
  • Tests about the step list should use plan.preview().steps.
  • Narrow leaf-renderer tests may call work_plan.render_steps() directly when reaching the case through plan_*() would require noisy unrelated setup.
  • New migrated command plans should store semantic work, not rendered steps.

See

  • cli/src/preview.rsPreview, PreviewNote, and canonical rendering.
  • cli/src/cmd.rsStep and dry-run command rendering.
  • docs/design/decisions/012-intent-cli.md – intent-command safety model and dry-run probe constraints.
  • plans/impl/2026-05-06-unify-cli-plan-execution.md – historical implementation plan for the migration that introduced this typed work-plan preview model.