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
WorkPlancontaining 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 byplan.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 throughplan_*()would require noisy unrelated setup. - New migrated command plans should store semantic work, not rendered steps.
See
cli/src/preview.rs–Preview,PreviewNote, and canonical rendering.cli/src/cmd.rs–Stepand 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.