# ADR-0027: Destructive CLI Commands Confirm by Default with `--force` and `--dry-run` Escape Hatches

## Status

✅ Accepted (2026-05-11; amended 2026-07-07 to narrow the MCP dry-run stance for
create/write tools, where preview-by-read is impossible, [#1048](https://github.com/rust-works/omni-dev/issues/1048))

## Context

omni-dev exposes a number of destructive Atlassian operations through both the
CLI and the MCP tool surface:

- `omni-dev atlassian jira delete`
- `omni-dev atlassian confluence delete`
- `omni-dev atlassian jira watcher remove`
- `omni-dev atlassian jira link remove` (added in [#711](https://github.com/rust-works/omni-dev/pull/711))
- `omni-dev atlassian confluence label remove`
- `omni-dev atlassian confluence attachment delete` (migrated onto the shared
  guard later, in [#1126](https://github.com/rust-works/omni-dev/issues/1126))

Before this decision, the surface was inconsistent. `jira delete` and
`confluence delete` already prompted interactively and accepted `--force`. The
other three executed the API mutation immediately with no guard, which made an
accidental piped argument or shell-history recall destructive on first use. The
two existing prompt implementations were also duplicated copies of the same
~10-line `confirm_with_reader` helper — a small but real source of drift.

The MCP surface had a parallel inconsistency. The `jira_delete` and
`confluence_delete` tools required a mandatory `confirm: true` parameter and
refused to execute otherwise. `jira_watcher_remove`, `jira_link_remove`, and
`confluence_label_remove` had no such guard, so an assistant could remove a
watcher or label by name without any opt-in step.

[Issue #720](https://github.com/rust-works/omni-dev/issues/720) raised the
question. Four options were weighed:

- **Confirm + `--force` (no `--dry-run`).** Catches accidental human invocations
  but offers no preview path: a script author can only verify by running the
  full command. Adequate but leaves an obvious gap.
- **`--dry-run` only, no interactive prompt.** Preserves CLI/MCP parity
  (neither side prompts) but relies on the operator to remember `--dry-run`. A
  one-character typo on the command line is still destructive.
- **Status quo + a documented "operator is responsible" convention.** Lowest
  implementation cost. Discarded because the asymmetry between commands that
  already prompt and commands that do not is itself the surprise — a documented
  convention does not undo muscle memory.
- **Confirm by default + `--force` + `--dry-run`.** Selected. The interactive
  prompt catches accidents on the human path; `--force` keeps scripts working;
  `--dry-run` adds a server-free preview that composes with `--force` so the
  same scripted invocation can be sanity-checked.

## Decision

### CLI

Every destructive Atlassian subcommand listed above prompts on stdin by default
and accepts two flags:

- `--force` — skips the interactive prompt.
- `--dry-run` — prints `Would <verb> <target>.` and returns without calling the
  API.

The flag spelling is `--force` rather than `--yes` for consistency with the two
pre-existing destructive commands; an alias was rejected to keep the surface
minimal.

A new shared helper `src/cli/atlassian/confirm.rs` exposes
`guard_destructive(opts) -> GuardOutcome { Proceed | Cancelled | DryRun }`. It
is the single source of truth for prompt shape, dry-run preview shape, and
flag precedence. The two duplicated `confirm_with_reader` copies were removed.
A third private copy in `confluence attachment delete` — which prompted and
accepted `--force` but lacked `--dry-run` — was missed at the time and was
migrated onto the shared helper in
[#1126](https://github.com/rust-works/omni-dev/issues/1126). Like label
removal (below), its dry-run preview echoes the attachment ID without a
lookup, keeping the "no API call" contract strict.

### MCP

Every destructive MCP tool requires a mandatory `confirm: bool` parameter and
returns an explanatory error when called without `confirm: true`. The three
tools that previously lacked the guard (`jira_watcher_remove`,
`jira_link_remove`, `confluence_label_remove`) now match the existing
`jira_delete` / `confluence_delete` shape.

At the time of this decision there was no `dry_run` parameter on the MCP
surface. The reasoning was that an assistant can preview a mutation by reading
the relevant resource (`jira_read`, `confluence_read`, `jira_watcher_list`,
`jira_link_list`, etc.) before mutating, so a per-tool dry-run mode would
diverge from the established `confirm` convention for no gain. That reasoning
holds for delete-style tools but was later found not to hold for create/write
tools; see [MCP dry-run for create/write tools](#mcp-dry-run-for-createwrite-tools)
below, added by amendment.

### MCP dry-run for create/write tools

*Added by amendment (2026-07-07,
[#1048](https://github.com/rust-works/omni-dev/issues/1048)).*

The "preview by reading first" rationale above is sound for
**delete-by-identifier** tools — the object already exists, so an assistant can
GET it before mutating. It does **not** hold for **create/write** tools: you
cannot read an object that does not yet exist, and the payload actually sent is
*derived* (JFM→ADF conversion, `createmeta` field resolution, required-field
validation) and only materialises at send time. Without a preview, the only way
to see the would-be request is to send it.

So a bounded exception was made. The following mutating tools now accept an
optional `dry_run: bool` (default `false`) that short-circuits before the
network call and returns the **would-be HTTP request** — method, path, and body
rendered as YAML — via the shared `src/mcp/dry_run.rs` helper
(`dry_run_request_yaml`), the MCP-side mirror of the CLI `--dry-run`:

- `jira_create`, `jira_write`, `jira_edit`
- `jira_link` — all three variants: create, parent, and remove
- `confluence_create`, `confluence_write`

Two further tools carry a `dry_run` field but render their preview through their
own path rather than the shared helper: `confluence_comment_reanchor` (via
`reanchor_inline_comment`) and the non-Atlassian `git_amend_commits` (via
`format_twiddle_payload`, outside this ADR's Atlassian scope).

Delete-style tools (`jira_delete`, `confluence_delete`, `jira_watcher_remove`,
`confluence_label_remove`) remain `confirm`-only: the preview-by-read path still
serves them, so the original rationale is unchanged for those tools.

`confirm` and `dry_run` are **orthogonal** affordances that compose on a single
tool. `jira_link` remove carries both: `confirm` is the destructive-action gate,
`dry_run` is the mutation preview. When `dry_run: true` is set it takes
precedence — the preview short-circuits before the network call *and* before the
`confirm` check, so a dry-run neither mutates nor requires `confirm`. This
mirrors the CLI "`--dry-run` wins over `--force`" rule in
[Flag precedence](#flag-precedence) below.

### Flag precedence

`--dry-run` wins over `--force`. Setting both produces the dry-run preview and
makes no API call. This is intentional: it lets a script author add `--dry-run`
to an existing scripted `--force` invocation as a temporary safety check
without removing the force flag. The alternative — declaring the combination a
parse-time error via `clap`'s `conflicts_with` — was considered and rejected
for that reason.

### Dry-run for label removal

`confluence label remove --dry-run` does not GET the page's existing labels
before printing its preview. Labels supplied on the command line are echoed
back verbatim, even if they do not exist on the page. This keeps the "no API
call" contract strict; the small honesty gap is accepted as a documented
trade-off rather than incurring a read round-trip on every dry-run.

### Sprint operations

`omni-dev atlassian jira sprint *` is out of scope. Investigation found no
destructive sprint subcommands (the `update` subcommand mutates state but does
not delete). If a destructive sprint operation is added later, it inherits the
same pattern.

## Consequences

**Positive**

- Uniform destructive-action UX across the six subcommands and three MCP
  tools. Operators no longer have to remember which commands prompt.
- Single shared helper reduces drift and gives one place to extend (richer
  prompt formatting, NO_COLOR awareness, etc.) in future.
- `--dry-run` provides a server-free way to sanity-check scripted invocations
  before re-running with `--force`.
- MCP parity with the established `confirm: true` pattern closes a real gap in
  the assistant interface.

**Negative**

- Breaking change for non-interactive callers of `jira watcher remove`,
  `jira link remove`, and `confluence label remove`: scripts that previously
  ran these commands without input must add `--force` or be rewritten to feed
  `y\n` on stdin.
- Breaking change for MCP callers of the same three tools: the `confirm` field
  is mandatory; calls without it now error.
- The label-remove dry-run is correctness-loose: labels that do not exist on
  the page still appear in the preview. Documented above.

**Neutral**

- The `--force` early-skip of the issue/page lookup in `jira delete` and
  `confluence delete` is preserved when `--force` is set on its own. When
  `--dry-run` is also set, the lookup runs so the preview can name the target.
- Adding `--dry-run` to commands that currently have `--force` (the two
  delete commands) does not change their behaviour for any existing flag
  combination.
- The MCP dry-run stance was **narrowed, not reversed**, after acceptance: an
  optional `dry_run` preview was added to the create/write tools (where
  preview-by-read is impossible), while delete-style tools stay `confirm`-only.
  See [MCP dry-run for create/write tools](#mcp-dry-run-for-createwrite-tools).
