# ADR-0036: `--partial` in `edit` — single-pair stays NO_MATCHES, multi-pair applies the matched ones
- **Status**: Accepted
- **Date**: 2026-06-15
- **Context**: G117 (ADR-0026) introduced the 9-strategy fuzzy cascade for multi-pair `edit` and the `PairResult { matched, strategy, similarity }` per-pair report, but did not define the behavior of `--partial` in the single-pair case. The flag was added to `EditArgs` as a placeholder, documented in `SKILL.md` as "opt-in — applies matching pairs and reports the rest", and the v0.1.19 audit (GAP-2026-005b) confirmed that the help never rendered the flag because the field's clap declaration shipped without an implementation. The v0.1.20 closure of GAP-2026-005b is the implementation of `--partial` itself, and that implementation surfaces a semantic question: when a single-pair `edit --old X --new Y --partial` cannot find `X` in the file, what should the command do? Three answers are coherent: (a) silently succeed with zero pairs applied and exit 0 (the "permissive" reading), (b) return `NO_MATCHES` (exit 1) to mirror `replace` behavior, (c) return `INVALID_INPUT` (exit 65) to mirror the all-or-nothing multi-pair error path. The multi-pair case is unambiguous: `--partial` applies the matched pairs, reports the unmatched in `pair_results`, and exits 0 with `edits < pairs_total`. But a single-pair call with zero matches has nothing to apply — "applied 0 of 1" is a degenerate case that, depending on the caller's parser, can look identical to a successful no-op edit. Without a clear semantic, downstream local gates that grep for `edits: 0` would silently pass a pipeline that should have failed.
- **Decision**: Adopt the asymmetry explicitly: **single-pair `--partial` with no match returns `NO_MATCHES` (exit 1); multi-pair `--partial` applies matched pairs and reports unmatched in `pair_results` (exit 0 with `edits < pairs_total`)**. Concretely:
1. **Single-pair (`args.old.len() == 1`)**: in `edit_old_new_single` at `src/commands/edit.rs:438-462`, when `match_pair` returns `Err(_)`, the `Err(_) if args.partial` arm maps to `AtomwriteError::NoMatches` (exit 1, no write performed). Rationale: the same single-pair invocation without `--partial` would have returned `INVALID_INPUT` (exit 65) — the `--partial` flag does not relax the failure semantics for the degenerate single-pair case, because the caller did not actually request "apply the matching subset" (the subset is the empty set). Mirroring `NO_MATCHES` keeps the `replace --no-matches` semantic and gives local gates a single exit code to grep for ("if exit 1, the file was unchanged because nothing matched").
2. **Multi-pair (`args.old.len() > 1`)**: in `edit_old_new_multi` at `src/commands/edit.rs:650-705`, the per-pair loop accumulates `pair_results`; on `match_pair` error with `args.partial` set, the loop records a `PairResult { matched: false, ... }` and CONTINUES to the next pair instead of aborting. The write happens if and only if at least one pair matched. The exit is 0, the `EditOutput` envelope carries `pairs_total`, `edits` (the number actually applied), and the full `pair_results` array so the caller can audit which pairs were skipped.
3. **All-or-nothing default (no `--partial`)**: when the flag is absent, ANY unmatched pair in the multi-pair path aborts the whole batch with `AtomwriteError::EditPairFailed { failed_pair_index, total, pair_results }` (ADR-0026, exit 65). The single-pair path without `--partial` also aborts with `INVALID_INPUT` (exit 65). The default is strictly more conservative than `--partial`, so existing pipelines that did not opt in are unaffected.
4. **Zero-applied multi-pair edge case**: when `--partial` is set and zero pairs match, the multi-pair path returns `NO_MATCHES` (exit 1, no write). This mirrors the single-pair case to keep the empty-result exit code consistent across pair counts.
5. **Schema discipline**: the `edit-output.schema.json` `mode` field becomes `fuzzy-multi(N)` when any pair matched fuzzily and `exact-multi(N)` otherwise (ADR-0026 already pins this for the non-partial multi path); the `--partial` flag is not separately reflected in the schema, but `pairs_total` and `edits` together let a consumer detect the partial case by computing `edits < pairs_total`.
- **Consequences**:
- **+** The semantic is explicit and documented: single-pair `--partial` is effectively a no-op-with-warning (exit 1) because there is only one pair to apply; multi-pair `--partial` is a true partial application. A reader of the SKILL section can answer the "what does --partial do?" question by counting the pairs.
- **+** local gates can rely on `NO_MATCHES` (exit 1) as the universal "no match found" signal across both `edit` and `replace`, regardless of `--partial` or pair count. The behavior is consistent.
- **+** The all-or-nothing default is preserved: agents that pipe `edit` into a pipeline without `--partial` get the same atomicity guarantee as before. The `--partial` opt-in is a strictly additive escape hatch.
- **+** Multi-pair `--partial` returns a structured `pair_results` array, so a caller that wants to know which specific pairs failed can audit without re-running the edit. The diagnostic value of G117 is preserved.
- **-** The single-pair `--partial` behavior is counterintuitive at first glance: a user who passes `--partial` to a single-pair command and gets exit 1 may file a bug. Mitigated by the SKILL section that explicitly states "single-pair `--partial` with no match exits 1; multi-pair `--partial` applies what matched".
- **-** The exit-code table grows by one row (exit 1 for the single-pair partial case in addition to the existing exit 1 for the multi-pair zero-applied case). Both share the `NO_MATCHES` code, so the schema does not change, but the narrative documentation must be precise.
- **-** A caller that wanted to detect "single-pair, no match, --partial set" specifically (as opposed to "any no-match case") would need to look at the `mode` field rather than the exit code. The mode field is a string discriminator that the existing pipelines already parse, so this is an additive dependency, not a breaking one.
- **Rationale**: The single-pair case cannot be partial by definition — there is exactly one pair, so the matching subset is either the full set (matched) or the empty set (no match). The `--partial` flag in this degenerate case has no observable effect on the *write*, only on the *error code*: it downgrades `INVALID_INPUT` (exit 65, "the file is broken and your edit was structurally rejected") to `NO_MATCHES` (exit 1, "the file is fine but your old string was not present"). The downgrade is useful: an automated editor that retries on transient mismatches wants exit 1, not exit 65, so it can distinguish "try again with a different --old" from "abort the batch". The multi-pair case, by contrast, has a real partial semantics: "apply 7 of 10 pairs, report the 3 that didn't match". The asymmetry is therefore principled, not arbitrary. ADR-0026 already established that `--partial` defaults to off (all-or-nothing) to preserve atomicity; the v0.1.20 single-pair behavior is a natural consequence of that same all-or-nothing principle applied at the unit-of-failure level (one pair).
- **Examples**:
- `atomwrite edit foo.rs --old "ALPHA" --new "beta"`: no match (ALPHA absent), `--partial` not set → `INVALID_INPUT` (exit 65), no write, error envelope on stdout.
- `atomwrite edit foo.rs --old "ALPHA" --new "beta" --partial`: no match, `--partial` set → `NO_MATCHES` (exit 1), no write, error envelope on stdout. Caller sees exit 1 and can either retry with a different `--old` or treat the absence as expected.
- `atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y"`: `A` matches, `B` does not, `--partial` not set → `INVALID_INPUT` (exit 65), no write, envelope includes `failed_pair_index: 2, total: 2, pair_results: [...]`.
- `atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y" --partial`: `A` matches, `B` does not, `--partial` set → exit 0, write performed (only the A→X substitution), envelope `pairs_total: 2, edits: 1, pair_results: [{matched: true, ...}, {matched: false, ...}]`.
- `atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y" --partial`: NEITHER matches, `--partial` set → `NO_MATCHES` (exit 1), no write. Mirrors the single-pair case at the multi-pair level: zero applied pairs maps to the same exit code as one unmatched pair.
- **Alternatives considered**:
1. Make single-pair `--partial` exit 0 with `edits: 0`. Rejected: a successful exit with zero applied pairs is a footgun. local gates that grep for exit 0 would silently pass a pipeline that should have flagged the missing old string. `NO_MATCHES` (exit 1) is the conservative, grep-discoverable signal.
2. Make single-pair `--partial` exit 0 and apply the `new` string at the end of the file. Rejected: an unprompted append is not an "edit", it is a `write --append`, and conflating the two violates the principle that verbs should match semantics. The `edit` verb implies "find X, replace with Y"; if X is absent, the operation is a no-op or a failure, never an append.
3. Unify single and multi behavior under one rule: `--partial` always applies the matching subset, exit 0 if at least one pair applied, exit 1 if none. Rejected: the single-pair case has no "subset" — it has either 1 or 0 applications. Treating "applied 1 pair" identically to "applied 7 of 10 pairs" obscures the structural difference. The asymmetry is the cleanest model.
4. Add a new exit code (e.g. exit 67 `EDIT_PARTIAL_EMPTY`) for the single-pair partial case. Rejected: 26 exit codes is already a maintenance burden; the v0.1.20 SKILL exit-code table would grow by one row for a degenerate case. The `NO_MATCHES` code is the existing canonical signal for "the operation found nothing to do" and the single-pair partial case is exactly that.
- **Trigger to revisit**: If a future caller requests "single-pair `--partial` should still write the file even when nothing matched" (e.g. a templating tool that treats `--old` as a hint and `--new` as an unconditional append), revisit the semantic. If the multi-pair zero-applied case becomes a usability problem (the caller cannot tell whether `--partial` was set or the input was empty), add a `pairs_applied: 0` discriminator to the error envelope. If a v0.2.0 release introduces a new exit code namespace, re-evaluate the `NO_MATCHES` reuse for the single-pair partial case.