-
Status: Accepted
-
Date: 2026-06-15
-
Context: G117 (ADR-0026) introduced the 9-strategy fuzzy cascade for multi-pair
editand thePairResult { matched, strategy, similarity }per-pair report, but did not define the behavior of--partialin the single-pair case. The flag was added toEditArgsas a placeholder, documented inSKILL.mdas "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--partialitself, and that implementation surfaces a semantic question: when a single-pairedit --old X --new Y --partialcannot findXin 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) returnNO_MATCHES(exit 1) to mirrorreplacebehavior, (c) returnINVALID_INPUT(exit 65) to mirror the all-or-nothing multi-pair error path. The multi-pair case is unambiguous:--partialapplies the matched pairs, reports the unmatched inpair_results, and exits 0 withedits < 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 foredits: 0would silently pass a pipeline that should have failed. -
Decision: Adopt the asymmetry explicitly: single-pair
--partialwith no match returnsNO_MATCHES(exit 1); multi-pair--partialapplies matched pairs and reports unmatched inpair_results(exit 0 withedits < pairs_total). Concretely:- Single-pair (
args.old.len() == 1): inedit_old_new_singleatsrc/commands/edit.rs:438-462, whenmatch_pairreturnsErr(_), theErr(_) if args.partialarm maps toAtomwriteError::NoMatches(exit 1, no write performed). Rationale: the same single-pair invocation without--partialwould have returnedINVALID_INPUT(exit 65) — the--partialflag 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). MirroringNO_MATCHESkeeps thereplace --no-matchessemantic and gives local gates a single exit code to grep for ("if exit 1, the file was unchanged because nothing matched"). - Multi-pair (
args.old.len() > 1): inedit_old_new_multiatsrc/commands/edit.rs:650-705, the per-pair loop accumulatespair_results; onmatch_pairerror withargs.partialset, the loop records aPairResult { 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, theEditOutputenvelope carriespairs_total,edits(the number actually applied), and the fullpair_resultsarray so the caller can audit which pairs were skipped. - All-or-nothing default (no
--partial): when the flag is absent, ANY unmatched pair in the multi-pair path aborts the whole batch withAtomwriteError::EditPairFailed { failed_pair_index, total, pair_results }(ADR-0026, exit 65). The single-pair path without--partialalso aborts withINVALID_INPUT(exit 65). The default is strictly more conservative than--partial, so existing pipelines that did not opt in are unaffected. - Zero-applied multi-pair edge case: when
--partialis set and zero pairs match, the multi-pair path returnsNO_MATCHES(exit 1, no write). This mirrors the single-pair case to keep the empty-result exit code consistent across pair counts. - Schema discipline: the
edit-output.schema.jsonmodefield becomesfuzzy-multi(N)when any pair matched fuzzily andexact-multi(N)otherwise (ADR-0026 already pins this for the non-partial multi path); the--partialflag is not separately reflected in the schema, butpairs_totalandeditstogether let a consumer detect the partial case by computingedits < pairs_total.
- Single-pair (
-
Consequences:
-
- The semantic is explicit and documented: single-pair
--partialis effectively a no-op-with-warning (exit 1) because there is only one pair to apply; multi-pair--partialis a true partial application. A reader of the SKILL section can answer the "what does --partial do?" question by counting the pairs.
- The semantic is explicit and documented: single-pair
-
- local gates can rely on
NO_MATCHES(exit 1) as the universal "no match found" signal across botheditandreplace, regardless of--partialor pair count. The behavior is consistent.
- local gates can rely on
-
- The all-or-nothing default is preserved: agents that pipe
editinto a pipeline without--partialget the same atomicity guarantee as before. The--partialopt-in is a strictly additive escape hatch.
- The all-or-nothing default is preserved: agents that pipe
-
- Multi-pair
--partialreturns a structuredpair_resultsarray, 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.
- Multi-pair
-
- The single-pair
--partialbehavior is counterintuitive at first glance: a user who passes--partialto a single-pair command and gets exit 1 may file a bug. Mitigated by the SKILL section that explicitly states "single-pair--partialwith no match exits 1; multi-pair--partialapplies what matched".
- The single-pair
-
- 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_MATCHEScode, so the schema does not change, but the narrative documentation must be precise.
- 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
-
- 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
modefield 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.
- 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
-
-
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
--partialflag in this degenerate case has no observable effect on the write, only on the error code: it downgradesINVALID_INPUT(exit 65, "the file is broken and your edit was structurally rejected") toNO_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--partialdefaults 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),--partialnot set →INVALID_INPUT(exit 65), no write, error envelope on stdout.atomwrite edit foo.rs --old "ALPHA" --new "beta" --partial: no match,--partialset →NO_MATCHES(exit 1), no write, error envelope on stdout. Caller sees exit 1 and can either retry with a different--oldor treat the absence as expected.atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y":Amatches,Bdoes not,--partialnot set →INVALID_INPUT(exit 65), no write, envelope includesfailed_pair_index: 2, total: 2, pair_results: [...].atomwrite edit foo.rs --old "A" --new "X" --old "B" --new "Y" --partial:Amatches,Bdoes not,--partialset → exit 0, write performed (only the A→X substitution), envelopepairs_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,--partialset →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:
- Make single-pair
--partialexit 0 withedits: 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. - Make single-pair
--partialexit 0 and apply thenewstring at the end of the file. Rejected: an unprompted append is not an "edit", it is awrite --append, and conflating the two violates the principle that verbs should match semantics. Theeditverb implies "find X, replace with Y"; if X is absent, the operation is a no-op or a failure, never an append. - Unify single and multi behavior under one rule:
--partialalways 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. - 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. TheNO_MATCHEScode is the existing canonical signal for "the operation found nothing to do" and the single-pair partial case is exactly that.
- Make single-pair
-
Trigger to revisit: If a future caller requests "single-pair
--partialshould still write the file even when nothing matched" (e.g. a templating tool that treats--oldas a hint and--newas an unconditional append), revisit the semantic. If the multi-pair zero-applied case becomes a usability problem (the caller cannot tell whether--partialwas set or the input was empty), add apairs_applied: 0discriminator to the error envelope. If a v0.2.0 release introduces a new exit code namespace, re-evaluate theNO_MATCHESreuse for the single-pair partial case.