atomwrite 0.1.26

Atomic file operations CLI for LLM agents — read, write, edit, search, replace with NDJSON output
Documentation
# ADR-0040: prune-backups subcommand — cleanup manual de backups legados

- **Status**: Accepted
- **Date**: 2026-06-17
- **Context**: v0.1.21 (ADR-0038) changed the default for `keep_backup` to `false`: backups are deleted after success, and operators who want forensic retention pass `--keep-backup`. The migration of v0.1.20 backups in-place is out of scope for v0.1.21 — the audit explicitly classified pre-v0.1.21 `.bak.<timestamp>` siblings as "transient trash that the operator can clean up at their leisure". The GAP-2026-014 v2 closure documented that `cleanup_old_backups_in` (atomic.rs:833-858) still exists for the `--keep-backup` case (count-based prune) but does nothing for the legacy case where the operator used v0.1.20's implicit backup ring. Operators who upgrade from v0.1.20 to v0.1.21 inherit one `.bak.<timestamp>` sibling per `--backup` write they ever issued. For long-running CI hosts that upgraded weekly, this can be tens of thousands of files. The audit surfaced a need for a one-shot cleanup that is: (a) explicit (the operator runs it on purpose, not as a side effect of another subcommand), (b) safe (defaults that cannot cause data loss without a confirmation step), and (c) composable (uses the same `--workspace` jail and the same NDJSON envelope as the rest of the CLI). The `rules-rust-cli-one-shot §Daemon Proibido` invariant forbids any background process or scheduler — the cleanup must be a one-shot invocation that the operator runs in a script or in a terminal. The `docs/AGENTS.pt-BR.md` "Time Machine" section already documents the backup naming convention (`.bak.<YYYYMMDD_HHMMSS>`) and the `--workspace` jail rules, so the new subcommand inherits those contracts without inventing new ones.

- **Decision**: Add a new subcommand `prune-backups [PATHS]...` to v0.1.22 that deletes `.bak.<YYYYMMDD_HHMMSS>` siblings under the given paths (or under the workspace root if no paths are given). Three new flags control the deletion set: `--max-age <SECONDS>` (delete backups older than N seconds; default 0 = delete all), `--max-count <N>` (keep at most N most-recent backups per directory; default 0 = unlimited), and `--dry-run` (print the would-delete list to stdout as NDJSON without removing anything; default `true` for safety). The subcommand emits one NDJSON line per inspected backup: `{"path": "...", "age_secs": N, "size_bytes": N, "action": "deleted" | "kept" | "would_delete"}`. A final `summary` line reports `scanned`, `deleted`, `kept`, `elapsed_ms`, and `dry_run` (bool). The exit code is 0 if the scan completed (regardless of how many were deleted), 1 if no backups were found (NO_MATCHES), or 65 if any precondition failed (invalid age, invalid count, jail violation). The implementation lives in `src/commands/prune_backups.rs` and reuses the `validate_path` helper from the G121 path resolution layer (ADR-0031) to enforce the `--workspace` jail. The deletion uses `std::fs::remove_file` directly (not the atomic pipeline) because the target is a backup, not a primary file — the atomic pipeline's `tempfile + fsync + rename` is unnecessary when the target is the disposable sibling.

- **Consequences**:
  - **+** Operators with v0.1.20-era backup rings have a one-line command to clean them up: `atomwrite --workspace . prune-backups --max-age 0` deletes everything older than 0 seconds (i.e. everything). The `--dry-run` default `true` makes the command safe to paste from a chat thread without losing data.
  - **+** The `--max-count` flag covers the `--keep-backup` case from a different angle: an operator who wants "keep the 3 most recent backups per directory" can run `prune-backups --max-count 3` on a schedule. The flag does not require `--keep-backup` to have been used; it operates on the on-disk state regardless of how the backups were created.
  - **+** The subcommand is composable with the rest of the CLI: `prune-backups` is a one-shot invocation, emits NDJSON, respects the `--workspace` jail, and does not require a long-running process. The output NDJSON is consumable by `jaq` for scripting (`prune-backups --dry-run | jaq -r 'select(.action == "would_delete") | .path'`).
  - **+** Backups deleted by `prune-backups` are not subject to the v0.1.21 `keep_backup: false` default (which only affects post-success deletion of the just-created backup). The subcommand operates on existing files, not on the backup lifecycle of a new write.
  - **-** The `--max-age 0` default combined with `--dry-run` default `true` means that the most common mistake (operator forgets `--dry-run` is already on and the second invocation deletes everything) is mitigated by the default. The opposite mistake (operator passes `--dry-run false` and loses data) is mitigated by the explicit flag name and by the `summary.dry_run` field in the output, but cannot be prevented by the subcommand itself.
  - **-** The subcommand expands the help surface by one entry and adds 3 flags. The flag count is the minimum needed to express "all", "older than N seconds", and "keep at most N": the audit identified these as the three retention models that operators actually use.
  - **-** Backups deleted by `prune-backups` are irretrievable. The VCS may have the pre-write state, but the v0.1.20 backup ring was explicitly framed as a forensic safety net for the pre-VCS-write moment. An operator who relied on the backup as the only copy of a file and runs `prune-backups --dry-run false --max-age 0` will lose that copy. The `summary.dry_run` field and the explicit `--dry-run` flag are the only guards.

- **Rationale**: The 2026-06-15 audit (ADR-0035) and the v0.1.21 release (ADR-0038) closed the future-facing problem (new writes do not leave persistent trash) but left the past-facing problem (legacy v0.1.20 backups persist) for an explicit operator action. The audit rejected the "age-based prune built into the atomic pipeline" alternative (ADR-0038, alternative 3) on the grounds that it reintroduces the "when do they go away" question that v0.1.21 resolved by making the answer "after the write that created them succeeds". For the legacy case, the answer must be "when the operator runs `prune-backups`", because the pre-v0.1.21 backups were created by a different code path with different semantics, and no automatic retention rule can be applied retroactively without surprising the operator. The subcommand is the smallest surface that addresses the legacy case: one subcommand, three flags, one default (`--dry-run true`) that cannot cause data loss. The default `--dry-run true` is the inverse of the project convention (defaults are usually `false`) and is justified by the irreversible nature of the operation: the audit's VAI-PSIQUE-CHECK (valide-ação-irreversível-pré-psique) is built into the default. The subcommand does not interfere with the v0.1.21 default behavior: v0.1.21 writes that pass `--keep-backup` create backups that `prune-backups` can also manage, so a single cleanup command covers both the legacy and the current retention models.

- **Alternatives considered**:
  1. **Auto-prune on every write that creates a backup.** Rejected by ADR-0038 (alternative 3) for the same reason: a 0-second age is "delete immediately" and a non-zero age reintroduces the "when" question. The subcommand is the explicit counterpart.
  2. **Auto-prune on `init` or on first write of a session.** Rejected: the v0.1.20 backups are user data (a forensic safety net the operator chose to create); silently deleting them on `init` would be a data-loss bug, not a cleanup. The subcommand's `--dry-run true` default and explicit invocation is the only safe path.
  3. **Build the cleanup into `cleanup_old_backups_in` (the count-based prune from atomic.rs:833-858).** Rejected: `cleanup_old_backups_in` operates on the backup ring of a single in-progress write, not on the global state of `.bak.*` files on disk. Conflating the two would force a refactor of the atomic layer and would couple the v0.1.21 lifecycle to the legacy cleanup problem. The subcommand keeps the two concerns separate.
  4. **Ship a separate one-shot binary (`atomwrite-prune-backups`).** Rejected: violates the `rules-rust-cli-one-shot` invariant (one binary, many subcommands) and forces operators to manage a second installation. The subcommand is the same binary, same NDJSON envelope, same jail rules.

- **Trigger to revisit**: If operators report that the `--dry-run true` default is annoying (they want to chain `prune-backups --dry-run false` in a script), add a `--force` flag that requires a confirmation step (TTY prompt or `--yes` to skip) and switch the default to `--dry-run false` for that flag combination. If the VAI-PSIQUE-CHECK proves insufficient and operators report accidental data loss, add a `--backup-of-backup` flag that creates a `.bak.<timestamp>.prune-safety` copy of every backup before deletion, at the cost of doubling the disk usage during the cleanup. If a future subcommand (e.g. `prune-journals`, `prune-sidecars`) needs the same per-pattern cleanup, extract the NDJSON output and the `--max-age`/`--max-count` flag semantics into a shared helper rather than copy-pasting the pattern.

- **Addendum (v0.1.22 implementation)**: During execution we discovered two corrections that the original ADR did not anticipate:

  1. **`--max-count N` semantics**: The original spec said "keep at most N most-recent". The initial implementation had `truncate(max_count)` after reversing the sort, which deleted the N most-recent backups instead of the N oldest. The fix is `truncate(backups.len().saturating_sub(max_count))` on the ascending sort (delete the oldest prefix). The regression test `prune_backups_max_count_keeps_newest_n` pins the corrected behavior.

  2. **VAI-PSIQUE-CHECK hardening**: When neither `--max-age-secs` nor `--max-count` is provided, clap resolves `Option<u8>::None` to `None` (no filter), and the prune loop would silently delete every backup matching the target. The implementation now refuses with `bail!()` ("refusing to prune without --max-age-secs or --max-count; pass at least one to define the retention policy"). The regression test `prune_backups_no_filter_aborts_with_error` pins this safety check.

  Both corrections are documented in the test names themselves; future readers of the codebase can trace the intent back to this addendum.