# ADR-0035: Six-layer defense-in-depth for the `write` subcommand
- **Status**: Accepted
- **Date**: 2026-06-15
- **Context**: The `write` subcommand is atomic by design (G114, ADR-0023) but, until v0.1.20, it had no guard between "operator intent" and "content on disk". An operator who meant `edit` and typed `write` — or who mistyped the stdin source — could truncate a large file in one syscall, with the only safety net being a `--backup` flag they had to remember to set. The motivating incident occurred during the 2026-06-15 audit: the operator executed `atomwrite write <small_new_content>` against `c24-framework34.html` (491827 bytes). The file was truncated to the new content size in a single atomic rename. A manual `cp` backup existed from 2026-06-14 23:49 but did not cover the work done on 2026-06-15; approximately 127 lines (around 9 KB) of that day's work were unrecoverable. The G118 (path resolution, ADR-0027) and G120 (empty-stdin guard) fixes protect against a different class of bug (path identity drift and empty input); neither validates the *size delta* between the existing and new content. The `edit` vs `write` boundary is a UX trap: clap accepts both for any existing file, the help text does not warn about the destructive default, and an LLM agent that picks `write` based on a "rewrite the whole file" heuristic will erase work the user did not intend to discard. The previous default — "operator must remember `--backup`" — is the same anti-pattern that produced GAP-2026-002 (`--preserve-timestamps` missing from `write`) and GAP-2026-011 (no size guard). A single, opt-in flag is not enough; the audit needs a layered defense that fails safe at multiple levels so that a missed flag at one layer does not translate into a data loss at the rename.
- **Decision**: Add six independent guards to the `write` subcommand, each addressable from the CLI and each emitting a structured `WriteRiskAssessment` field on the envelope when triggered. The layers are:
1. **L1 — Size-delta diagnostics (local; not product telemetry)**: when the target exists, compute `size_delta_pct = |new_bytes - original_bytes| * 100 / original_bytes`. If the delta meets or exceeds `--risk-threshold` (default 50, exposed via `WriteArgs`), classify the operation as `low` (50-69%), `medium` (70-89%), or `high` (>=90%) and emit a yellow `warning:` line on stderr with the level, percentage, and byte counts. The corresponding `WriteOutput.risk_assessment` field carries the same `WriteRiskAssessment { original_bytes, new_bytes, size_delta_pct, risk_level, guard_triggered: "size" }`. The L1 guard is INFORMATIONAL by design — it does not block the write. Code reference: `src/commands/write.rs:153-186` (the `risk_assessment` computation block) and `src/ndjson_types.rs` (the `WriteRiskAssessment` struct).
2. **L2 — `--require-backup` hard guard**: if the flag is set, the operation ABORTS with `AtomwriteError::InvalidInput` (exit 65) unless `--backup` is also set. The check runs after path resolution (G118) and before the `exists()` branch, so it fires for both new and existing files. Code reference: `src/commands/write.rs:119-125`. Rationale: this is the closest thing to a local gate an operator can install in their own command line; it converts "I should remember `--backup`" into a structural invariant.
3. **L3 — `--confirm` interactive prompt**: when the flag is set AND the target exists AND the existing file is larger than 100 KB, print `Overwrite <path> (<bytes> bytes)? [y/N]` to stderr, read one line from stdin, and abort with `bail!("aborted by user (confirm=no)")` if the response is not `y` or `yes`. Code reference: `src/commands/write.rs:127-141`. The 100 KB threshold is hard-coded in v0.1.20; making it configurable is a v0.1.21+ follow-up.
4. **L4 — `--preview` diff before apply**: when the flag is set, the command emits the structural diff (same NDJSON schema as the `diff` subcommand) via `crate::diff::structural_diff` BEFORE the `atomic_write` call, then waits for an explicit `--yes` (or a default-deny exit). The preview is the operator's last chance to see what will be replaced.
5. **L5 — `--auto-rotate` recently-modified heuristic**: when `--backup` is also set and the existing file was modified within the last 24 hours (`std::fs::metadata(...).modified().elapsed() < 24h`), the backup is forced even if `--backup` was not explicitly passed. Code reference: `src/commands/write.rs:143-151`. The 24-hour window is the same heuristic the snapshot tool uses; the L5 guard exists because the most common data-loss case is "I overwrote the file I was just editing, the previous backup is from yesterday and is now stale".
6. **L6 — `risk_assessment` NDJSON field**: every `WriteOutput` envelope includes an `Option<WriteRiskAssessment>` field; `Some(_)` means at least one guard fired. The agent or downstream pipeline can grep for `"risk_assessment":` and gate further actions on the `risk_level` value.
- **Consequences**:
- **+** The 2026-06-15 incident class (overwriting a ~500 KB file with a small payload) now produces a yellow `warning: write risk: high (99% delta, 491827 -> 127 bytes)` line AND a structured envelope field, even if the operator forgot every opt-in flag. The L1 guard alone is enough to make the loss recoverable on the next invocation (the operator sees the warning, aborts the pipeline, and restores from a manual backup).
- **+** Operators who want strict safety can pin `--require-backup` in their shell alias or wrapper script; the L2 guard turns a behavioral recommendation into a structural one.
- **+** The L3 prompt is interactive but bounded to 100 KB+ files, so it does not slow down the common case of writing small config files.
- **+** The L5 auto-rotate works without operator action; the heuristic is conservative (24h window, requires `--backup` to also be present) and is documented in the `WriteOutput.wal_policy` field for observability.
- **+** Local diagnostics are structured: `WriteRiskAssessment` is a stable Rust type, exposed via the `write-risk.schema.json` schema, and indexed by `guard_triggered` so an auditor can count which layer fired in production.
- **-** Six new flags raise the surface area of `write`. The help is now longer; the SKILL needs a new "intention guards" subsection that explains the layered model rather than listing each flag in isolation.
- **-** The L1 threshold of 50% is a guess. A 51% delta is "low" while a 49% delta is silent; the threshold may need to move per workload. Mitigated by `--risk-threshold <N>` configurability.
- **-** The L3 prompt reads from stdin in a way that competes with the actual content stdin on a single-stream shell pipeline. A pipeline like `cmd | atomwrite write --confirm foo.txt` will feed the content into the prompt and the prompt answer into the content. The fix is documented in the SKILL: pass `--confirm` only in interactive sessions, never in pipes.
- **-** The L4 `--preview` flag is the most complex of the six; the diff emission must be NDJSON-shaped and the `--yes` confirmation flow is a small state machine. Both deserve dedicated tests; v0.1.20 ships with the basic emission path and the explicit-confirmation follow-up is a v0.1.21 task.
- **Rationale**: The single-flag approach (`--backup`) is what v0.1.19 had, and it failed in production: the operator either forgot the flag, or the backup was stale. Each of the six layers addresses a different failure mode: L1 catches the silent class (operator did not set any flag), L2 catches the "I should have required it in local scripts" class, L3 catches the "I would have said no if asked" class, L4 catches the "I did not see the diff before" class, L5 catches the "my last backup is older than my last edit" class, L6 catches the "I need postmortem data" class. The same defense-in-depth pattern is already established in the project: G114 (WAL sidecar) is L1 of crash recovery, G119 (auto-heal) is L3, and G120 (empty-stdin guard) is a content-validation guard analogous to L1 here. The 6-layer model is the write-intent analog of that crash-recovery layered model. The cost is six flags and one schema; the benefit is that the 2026-06-15 class of incident becomes mechanically detectable at multiple points in the pipeline.
- **Alternatives considered**:
1. A single `--safe` flag that turns on all six guards. Rejected: collapses the layered model into a binary choice, removes the operator's ability to opt into diagnostics without opting into blocking, and gives no per-layer diagnostic. The 2026-06-15 operator wanted to *know* the delta was 99% (L1) without having to abort the write (L3).
2. Make L1 blocking by default above 90% delta. Rejected: changes the default semantics of `write` (which is documented as a destructive operation that replaces the target), breaks every existing pipeline that legitimately replaces a file with a smaller re-render (e.g. minification), and pushes the breakage into the long tail of `cargo install` upgrades.
3. Add a separate `safe-write` subcommand that bundles the six guards. Rejected: splits the API surface, doubles the maintenance burden, and the operator who already uses `write` would not migrate. The Agent-First CLI principle is to keep the destructive verb and add guards, not to introduce a new verb.
4. Skip L5 (auto-rotate) and rely on `--backup`. Rejected: `--backup` requires the operator to remember the flag, and the L5 heuristic exists precisely to catch the "I forgot" case for the high-frequency case (files modified within 24h).
- **Trigger to revisit**: If the 24-hour window in L5 turns out to be wrong for a workload (e.g. local automation systems that re-render files hourly), expose `--auto-rotate-window <SECONDS>`. If the L3 prompt's stdin collision with content stdin becomes a recurring pain point, split the prompt onto `/dev/tty` or a dedicated FD. If L4's diff emission grows in cost, cache the pre-write content hash and emit only the byte-level summary.