transit PR #193 commits 5 files 19 touched lines +846 / -112 tests 1567 passing findings 1 fixed, 8 skipped

Pre-push review: T-1935 editor conflict choices

PR #193Fix T-1935: Scope editor conflict choices. Reviewed across the three-dot range origin/main...HEAD (merge-base ac6cef95b7def6), so upstream commits merged into main after the branch point are excluded. The fixes applied during this review are committed as 5b7def6; the working tree is clean.

At a glance

  • Fixed: a field-independent rebase could pair an externally moved project with a milestone from the old project, making every subsequent save fail MilestoneService validation with a generic error and no way out.
  • Fixed: all six original consent assertions were == false; nothing pinned that valid consent survives. A stub returning false would have passed the suite while making every conflict alert impossible to get past.
  • Verified: make test-quick exit 0, 1567 tests. make lint 0 violations.
  • Residual risk: presentEditConflict re-presents a replaced alert after a single Task.yield(), which is not ordered against SwiftUI's own dismissal write. Fails safe and self-recovers; now recorded as a known limitation.
  • Not changed: duplicated conflict gate across three editors, and the now-redundant edited: applier parameter — the latter would require test edits, which the review constraints forbid for non-bug refactors.

Verdict

Ready to push — one correctness defect found and fixed

The core fix is sound. Retaining original/edited/live on EditMerge and deriving both rebase and consent from full snapshots rather than field-set booleans is the correct shape for this bug, and it removes both lost-update paths the report identifies.

The prior remediation stage reported REVIEW_RESULT: CLEAN. This review did not agree: a genuine correctness defect survived three review iterations. Because rebasedEdited merges fields independently, it could synthesise a project/milestone pairing that neither side ever held, leaving the task editor permanently unable to save. That is fixed here, with four regression tests. A second gap — every consent assertion checked only the negative direction, so an implementation returning false unconditionally would have passed the whole suite — is closed with two positive tests.

make test-quick exits 0 with 1567 tests passing; make lint reports 0 violations including the SwiftData ownership guard. One documented residual risk remains in presentEditConflict — see Double-check below.

Review findings

9 raised · 5 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Transit can be edited from more than one place at once — you might have a task open while an AI agent updates it over MCP, or while CloudKit syncs a change from your iPad. To handle that, the editors compare three versions of the record: original (what it looked like when you opened the editor), edited (what's in the form now), and live (what's in the database this instant). If you and someone else both changed the same field, the app stops and asks: Keep My Changes or Use Updated Values.

The bug was that both answers could still throw away changes you never saw.

"Use Updated Values" was too narrow. It refreshed only the fields that conflicted. If an agent renamed the task and rewrote its description while you had only retyped the name, the name got refreshed but the description didn't — and the app then declared everything on screen up to date. On your next save, that stale description looked like something you had typed, and overwrote the agent's version.

"Keep My Changes" was too broad. It set one yes/no flag meaning "the user said overwrite." But the alert sits on screen while you read it, and writers keep writing. If the agent changed the field again, or created a second conflict, that flag still said overwrite — authorising values you were never shown.

The fix makes both answers about specific values rather than fields. "Use Updated Values" now rebuilds the whole form from the live record and puts back only the edits you genuinely made that nobody else touched. "Keep My Changes" remembers exactly what the alert displayed and re-checks before saving; if anything changed, it discards the old answer and shows a fresh alert.

Why It Matters

Silent data loss is the worst kind of bug — nobody notices until much later. The conflict alert exists precisely to prevent lost updates, and it was leaking in both directions, across all three editors.

Key Concepts

  • Three-way merge — deciding what to write by comparing three versions instead of two, the way Git merges branches.
  • Snapshot — a frozen plain copy of a record's values, disconnected from the database so it can't change underneath you.
  • Baseline — the "original" snapshot everything is measured against. Advancing it incorrectly is what caused the bug.
  • Rebase — rebuilding your work on top of someone else's newer version rather than beside it.
  • Consent scoping — approval covering one specific thing ("overwrite this name with that name") rather than a blanket permission.
  • Lost update — two writers, one silently clobbering the other.

Changes Overview

Services/EditMerge.swift — the generic core. EditMerge now stores original, edited, and live alongside the derived changedFields/conflictingFields sets; previously it discarded the snapshots, which is exactly why neither follow-up action could be implemented correctly. Two new members:

  • rebasedEdited folds changedFields.subtracting(conflictingFields) over live, overlaying each surviving user edit. Starting from live is the key inversion — the old code started from the form and patched conflicts, so anything not classified as a conflict stayed stale.
  • hasSameConflictSnapshot(as:) compares the conflicting field set and, per field, the original/edited/live values. Field-name equality alone is insufficient: an external writer can change the same field twice while the alert is open.

The EditSnapshot protocol gains replacing(_:withValueFrom:), implemented per editor as a small switch.

Views/Shared/EditConflictAlert.swiftkeepMine changes from () -> Void to (EditMerge<Snapshot>) -> Void so both buttons receive the merge actually rendered. Adds presentEditConflict, which sets the binding directly for a fresh alert or clears-and-re-sets after a yield when replacing one mid-dismissal.

The three editorsoverwritingConflicts: Bool is replaced by consentingTo: EditMerge?. Both save and adoptLiveValues recompute from current state before acting and re-present when the snapshot no longer matches.

Implementation Approach

The organising insight is that both operations were modelled as field-set operations when they are snapshot operations. Putting both on the generic EditMerge gives all three editors identical semantics for free and makes both invariants unit-testable without a view. This continues the T-1798 → T-1817 trajectory: policy migrates into the shared merge layer, editors keep only state binding.

adoptLiveValues also advances the baseline to merge.live rather than re-reading the model. Since the form is populated from merge.rebasedEdited, itself derived from merge.live, form and baseline are guaranteed consistent — the old code read the model twice and could observe two different states.

Trade-offs

  • Exact-value consent is deliberately conservative. Any change to a shown conflict revokes the answer, even one the user would have answered identically. Matching field names only was rejected because a second external value for the same field was never displayed.
  • EditMerge grew from two Sets to two Sets plus three snapshots. Negligible for a single-user app editing one record; it buys testability and correctness.
  • Per-field replacing switches are duplicated three times, mirroring the existing differs switches. A keypath accessor could state the mapping once, at the cost of closure indirection. Left as-is deliberately.

Technical Deep Dive

Why starting from live is load-bearing. The old adoptLiveValues computed form ∪ {conflicting ← live} then set baseline ← live. For any field where live ≠ original that was not in conflictingFields (the user hadn't touched it), the form retained original's value while the baseline moved to live. The next merge classifies that field as changed — a phantom user edit that overwrites the external value. rebasedEdited inverts the construction to live ∪ {changed \ conflicting ← edited}, making the post-rebase invariant provable: every field equals either live (no change recorded) or a genuine user edit (change correctly recorded). The reduce over a Set<Field> is order-independent since each iteration writes a disjoint stored property.

Consent scoping and the modal window. hasSameConflictSnapshot compares all three snapshots, but only the live comparison can realistically differ: original is reassigned solely in load/adoptLiveValues, and edited derives from form state unreachable behind a modal alert. The other two are defence-in-depth against a future non-modal presentation. Note what the predicate deliberately does not cover: an external write to a field the user never edited doesn't enter conflictingFields and so doesn't revoke consent — correct, because such a field isn't in changedFields, the applier passes nil, and the external value survives untouched.

Cross-field consistency after a field-independent rebase. This is the subtle failure mode found and fixed during this review. rebasedEdited merges fields independently, so it can synthesise a combination neither side ever held. Concretely: task in project P1 with no milestone; user picks milestone M2 (in P1) and retypes the name; externally the task is renamed differently and moved to P3. .milestone is changed but not conflicting (live milestone still nil = original), so it is overlaid onto a live carrying project P3 — yielding P3 paired with M2 from P1.

The consequences cascade: the picker's onChange(of: selectedProjectID) guard is newValue != task.project?.id, and newValue is the live project, so auto-clear never fires. availableMilestones filters M2 out, so the picker renders blank. The next save sees changed(.milestone), calls MilestoneService.setMilestone, throws .projectMismatch, and is caught by the generic handler as "Could not save task. Please try again." Retrying always fails — the editor is unsaveable with no indication why.

Architecture Impact

EditMerge is now a genuine value-semantics merge object rather than a classification result. That is the right shape: consent, rebasing, and conflict description are all derivable from the three snapshots, and adding a fourth editor requires no new policy.

The asymmetry that made this bug possible survives: ProjectEditForm and MilestoneEditForm are testable value types, while task draft state remains eight @State properties in TaskEditView. That is why the task rebase needed a bespoke view-level helper while the other two are pure form methods — and why the cross-field defect landed on the task path specifically. Extracting a TaskEditForm would close the gap.

Potential Issues

  • presentEditConflict rests on an unverified ordering. await Task.yield() resumes on the next main-actor turn; the alert's isPresented setter writes nil when SwiftUI completes dismissal. Nothing orders the two. If the dismissal write lands last, the replacement alert is swallowed and the button appears inert. Fails safe and self-recovers, but is untested — SwiftUI alert ordering is not unit-testable, and neither make test nor make test-ui has produced a clean run on this branch.
  • Both alert buttons can re-present, including the .cancel-role one. Under a sufficiently aggressive external writer there is no path out of the alert. The required write rate is not realistic for MCP or CloudKit.
  • Form values are now normalised on adopt. adoptLiveValues populates from snapshots, whose initialisers apply trimmedForFormInput() (and normalizedHex for projects). In-progress text like "Foo " is silently trimmed when picking "Use Updated Values" on an unrelated conflict. Cosmetic and harmless — form and baseline normalise identically — but uncovered by tests.

Important changes — detailed

EditMerge: retain snapshots, derive rebase and consent from them

Transit/Transit/Services/EditMerge.swift

Why it matters. The whole fix hinges on this. Previously the merge computed changedFields/conflictingFields and discarded the three snapshots, so neither a correct rebase nor value-scoped consent was expressible. rebasedEdited inverts the rebase to start from live, which is what makes the post-rebase invariant provable.

What to look at. EditMerge.swift:37-39 (stored snapshots), :64-70 (rebasedEdited), :77-85 (hasSameConflictSnapshot)

Takeaway. When a follow-up action needs to know what the user was shown, the classification result is not enough — retain the inputs. A Boolean 'user approved' flag cannot express approval of specific values, and any race that changes those values silently widens the approval.
Rationale. Keeping snapshot identity and rebase policy in the shared merge layer gives all three editors identical behaviour and makes both race and rebase invariants testable without a SwiftUI view.

TaskEditView+Milestones: drop a rebased milestone from the wrong project

Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift

Why it matters. Found during this review. A field-independent rebase can pair an externally moved project with a milestone the user picked under the old one — a combination MilestoneService.setMilestone rejects. The picker renders blank, every save fails with the generic 'Could not save task' error, and retrying never helps. The editor becomes permanently unsaveable.

What to look at. TaskEditView+Milestones.swift:30-51 (rebasedMilestone), TaskEditView.swift:373-380 (call site)

Takeaway. A merge that resolves fields independently can synthesise a cross-field combination neither side ever held. Wherever the domain has a pairing rule (here Decision 6: moving project clears the milestone), re-apply it to the merged result — validating each field in isolation is not enough.
Rationale. Implemented as a static helper following the existing availableMilestones pattern, so the logic is testable outside the view. Dropping the milestone rather than reverting the project is what Decision 6 already prescribes, and it discards only the user's own unsaved pick, never external data.

Editors: replace the Boolean overwrite bypass with value-scoped consent

Transit/Transit/Views/TaskDetail/TaskEditView.swift

Why it matters. This is the user-visible half of the race fix. Each editor now recomputes the merge immediately before acting and proceeds only if the current conflicts still match what the alert displayed, so a write landing while the modal is open can no longer be authorised retroactively.

What to look at. TaskEditView.swift:334-346 (save consent gate), :366-388 (adoptLiveValues); mirrored in ProjectEditView.swift:193-198 and MilestoneEditView.swift:150-155

Takeaway. A modal alert is not a lock. Anything that can write while it is open must be re-checked when the button is pressed, not when the alert was raised — the gap between the two is unbounded because it is the user's reading time.
Rationale. The former overwritingConflicts: Bool carried no identity for the fields or values the user actually reviewed, so it authorised every conflict found after the fact, including newly added ones.

Consent tests asserted only the negative direction

Transit/TransitTests/TaskEditConflictConsentTests.swift

Why it matters. All six original hasSameConflictSnapshot assertions were == false. An implementation returning false unconditionally would have passed the entire suite while making every conflict alert impossible to dismiss and no save behind one able to complete. The invalidation half was pinned; the survival half was not.

What to look at. TaskEditConflictConsentTests.swift:19-56 (both positive cases)

Takeaway. For any predicate that gates an action, assert both directions. A suite that only tests the rejecting branch cannot distinguish a correct implementation from one that rejects everything — and 'rejects everything' often looks like safety rather than a bug.
Rationale. Added a case where an unchanged conflict matches itself, and one where an external write to an untouched field does not revoke consent — the latter also pins that non-conflicting external changes stay outside the consent scope.

EditConflictAlert: shared re-presentation helper

Transit/Transit/Views/Shared/EditConflictAlert.swift

Why it matters. Both alert callbacks now receive the shown merge, which is what makes exact-snapshot validation possible for the keepMine branch. presentEditConflict centralises the dismiss/yield/re-present dance all three editors need. It is also the weakest link in the change — see the open question.

What to look at. EditConflictAlert.swift:31 (keepMine signature), :53-69 (presentEditConflict)

Takeaway. SwiftUI cannot re-present an alert on the same binding inside its own dismissal transaction; the binding must be cleared and re-set on a later turn. Worth knowing before writing any re-presenting alert flow.
Open question. Rationale not stated by the author and not inferable from the diff.

Key decisions

Consent is scoped to exact values, not field names.

hasSameConflictSnapshot requires the conflicting field set to match and, for each field in it, the original/edited/live values to be unchanged. Matching field names alone was rejected because an external writer can change the same field twice while the alert is open — the second value was never shown to the user, so consent cannot cover it.

The rebase drops a milestone that does not match the rebased project.

Applied during this review. The alternative — reverting the project to keep the milestone — was rejected because it would discard an external change, which is the exact class of bug T-1935 exists to fix. Dropping the milestone loses only the user's own unsaved pick, and it is what Decision 6 already prescribes for a project move. The blank picker makes the loss visible rather than silent.

"Use Updated Values" also re-validates the conflict snapshot.

Arguably unnecessary — the action writes nothing to the model, and rebasing onto the newest live values is strictly what the user asked for. It was retained deliberately as the conservative choice: showing the user the current values before adopting them is more informative than silently adopting a set they never saw. Dispositioned explicitly in PR review iteration 2 as "intentionally conservative". The cost is that under a rapidly-writing external agent both buttons re-present, with no path out of the alert; the required write rate is not realistic for MCP or CloudKit.

Snapshots are retained on EditMerge despite the derived sets being sufficient for the original purpose.

changedFields and conflictingFields are pure functions of the three snapshots, so this is technically redundant state. It is justified because both new operations need the values, not the classification: rebasedEdited needs live and edited, and hasSameConflictSnapshot needs all three. Recomputing the sets from snapshots on demand would trade a few bytes for repeated work on every access.

(inferred — not stated by the author.)
The redundant edited: parameter on the three appliers was left in place.

Every production call site now passes merge.edited, so the parameter can no longer disagree with the merge and is dead API surface that invites a future mismatch. Removing it would require editing test call sites across four test files. The pre-push review constraints forbid modifying tests for anything other than fixing an actual bug, so this was deliberately skipped rather than forced. Worth a follow-up.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorTaskEditView.adoptLiveValues — cross-field consistencyrebasedEdited merges fields independently, so an externally moved project could be paired with a milestone the user picked under the old project. The picker's onChange auto-clear does not fire (newValue equals the live project), availableMilestones filters the milestone out so the picker renders blank, and every subsequent save throws MilestoneService.Error.projectMismatch — surfaced as the generic 'Could not save task. Please try again.' Retrying always fails; the editor is permanently unsaveable with no indication which field is wrong.Extracted TaskEditView.rebasedMilestone(milestoneID:projectID:candidates:) following the existing availableMilestones static-helper pattern, adding a $0.project?.id == projectID guard so a mismatched candidate resolves to nil. Re-applies Decision 6 to the rebased draft. Four regression tests added covering the mismatch, the matching case, live-task resolution, and the nil draft.
majorTest coverage — hasSameConflictSnapshotAll six original assertions on hasSameConflictSnapshot were == false, and the method had no other test callers. An implementation returning false unconditionally would have passed the entire suite while completely breaking the feature: every conflict alert would bounce into a fresh alert and no save behind one could ever complete. The consent-survives invariant was entirely unpinned.Added TaskEditConflictConsentTests with two positive cases: an unchanged conflict matches itself, and an external write to a field the user never touched does not revoke consent. Placed in a new file because adding to TaskEditConcurrentUpdateTests.swift breached the 400-line SwiftLint file_length limit.
minorEditConflictAlert.presentEditConflict — SwiftUI lifecycleThe helper clears the binding then re-sets it after a single await Task.yield(). Nothing orders that yield against SwiftUI's own dismissal, which writes nil through the alert's isPresented setter. If the dismissal write lands after the re-set, the replacement alert is swallowed and the button appears inert.Not changed. It fails safe (nothing written, conflict state discarded rather than resolved) and self-recovers: a second Save finds pendingConflict == nil, takes the direct non-replacing branch, and presents correctly. The alternative — a dismissal-driven queue promoted from the isPresented setter — is a speculative refactor of SwiftUI internals that cannot be verified here: alert presentation ordering is not unit-testable and neither make test nor make test-ui produces a clean run on this branch. Recorded as a Known limitation in report.md with the recommended fix.
minorThree appliers — redundant edited: parameterTaskEditApplier/ProjectEditApplier/MilestoneEditApplier still take edited: alongside the merge, but every production call site now passes merge.edited. The parameter can no longer legitimately disagree with the merge, so it is dead API surface that invites exactly the snapshot/field-set mismatch this ticket is about. Raised independently by two review agents.Not changed. Removal requires editing call sites across four test files, and the review constraints forbid modifying tests for non-bug refactors. Recorded as a follow-up in the decisions section.
minorThree editors — duplicated conflict gateThe same five-line consent gate appears three times in save and three more times in adoptLiveValues. A generic consentIsCurrent helper alongside presentEditConflict would collapse all six; the generic-over-Snapshot shape is provably expressible since presentEditConflict already compiles with those constraints.Not changed. Purely stylistic, the code reads clearly as written, and adding indirection at iteration 3 of an otherwise-settled PR carries more risk than the duplication does. Noted for a future pass.
minorreport.md — red phase claimThe report claimed 'make test-quick failed while compiling ProjectEditConflictDetectionTests.swift ... This confirmed the required consent-scoping behavior was absent.' A compile failure on a newly named method is tautological, not behavioural evidence. The same applies to the three rebase tests, which use the new one-argument adoptLiveValues(for:) signature.Corrected to state the red phase was compile-only, and to note the exception: the project-mismatch tests added in this review do fail behaviourally without the rebasedMilestone project guard.
minorCLAUDE.md — EditMerge subsystem docsThe Service Layer bullet reads as a checklist for adding a fourth editor and no longer was one: EditSnapshot conformers must now also implement replacing(_:withValueFrom:). It also still described conflicts as merely 'surfaced', with no mention of snapshot retention or consent scoping.Amended to document snapshot retention, value-scoped consent re-validated at button-press time, and the new snapshot requirement.
nitreport.md — affected files tableThe EditConflictAlert.swift row said only 'Pass the shown merge to both alert actions', omitting presentEditConflict — the helper all three editors depend on for the race path. Every other row describes its file's full change.Row expanded; four new rows added for the files this review touched.
nitadoptLiveValues — normalisation on adoptThe form is now populated from snapshots, whose initialisers apply trimmedForFormInput() and normalizedHex. In-progress text like 'Foo ' is silently trimmed when the user picks 'Use Updated Values' on an unrelated conflict; the old code left non-conflicting fields untouched.Not changed. Cosmetic and arguably desirable, and harmless because form and baseline are normalised identically so no phantom edit is recorded. Documented in the expert-level explanation.

Per-file diffs

Click to expand.

Transit/Transit/Services/EditMerge.swift Modified +44 / -0
diff --git a/Transit/Transit/Services/EditMerge.swift b/Transit/Transit/Services/EditMerge.swiftindex 23badbe..b0233d8 100644--- a/Transit/Transit/Services/EditMerge.swift+++ b/Transit/Transit/Services/EditMerge.swift@@ -23,6 +23,12 @@ protocol EditSnapshot: Equatable {      /// Whether this snapshot and `other` hold different values for `field`.     func differs(from other: Self, in field: Field) -> Bool++    /// Returns a copy with one field taken from `other`.+    ///+    /// This lets the shared merge rebuild a draft from live values while+    /// overlaying only fields still owned by the user.+    func replacing(_ field: Field, withValueFrom other: Self) -> Self }  /// Three-way comparison deciding what an editor save should write.@@ -37,6 +43,12 @@ protocol EditSnapshot: Equatable { struct EditMerge<Snapshot: EditSnapshot>: Equatable {     typealias Field = Snapshot.Field +    /// Snapshots are retained because conflict choices authorize exact values,+    /// not every conflict that happens to exist when the button is handled.+    let original: Snapshot+    let edited: Snapshot+    let live: Snapshot+     /// Fields the user changed. Only these are written.     let changedFields: Set<Field> @@ -50,6 +62,10 @@ struct EditMerge<Snapshot: EditSnapshot>: Equatable {     var hasConflicts: Bool { !conflictingFields.isEmpty }      init(original: Snapshot, edited: Snapshot, live: Snapshot) {+        self.original = original+        self.edited = edited+        self.live = live+         var changed: Set<Field> = []         var conflicting: Set<Field> = [] @@ -71,6 +87,34 @@ struct EditMerge<Snapshot: EditSnapshot>: Equatable {         changedFields.contains(field)     } +    /// A draft rebased onto `live` for "Use Updated Values".+    ///+    /// Starting from all live values refreshes fields the user never touched.+    /// Only genuine user edits without conflicts are overlaid; conflicting edits+    /// are deliberately replaced by the live values the user chose to use.+    var rebasedEdited: Snapshot {+        changedFields+            .subtracting(conflictingFields)+            .reduce(live) { snapshot, field in+                snapshot.replacing(field, withValueFrom: edited)+            }+    }++    /// Whether the current conflicts are exactly the ones shown by an alert.+    ///+    /// Matching field names is insufficient: an external writer may change the+    /// same field again while the alert is open. Consent covers the original,+    /// edited, and live values for each shown conflict and nothing else.+    func hasSameConflictSnapshot(as shown: Self) -> Bool {+        guard conflictingFields == shown.conflictingFields else { return false }++        return conflictingFields.allSatisfy { field in+            !original.differs(from: shown.original, in: field)+                && !edited.differs(from: shown.edited, in: field)+                && !live.differs(from: shown.live, in: field)+        }+    }+     /// Conflicting field labels in declaration order, for the conflict alert.     var conflictingFieldNames: [String] {         Field.allCases
Transit/Transit/Services/TaskEditMerge.swift Modified +16 / -0
diff --git a/Transit/Transit/Services/TaskEditMerge.swift b/Transit/Transit/Services/TaskEditMerge.swiftindex c742b0e..2488c90 100644--- a/Transit/Transit/Services/TaskEditMerge.swift+++ b/Transit/Transit/Services/TaskEditMerge.swift@@ -96,6 +96,22 @@ nonisolated struct TaskEditSnapshot: EditSnapshot {         case .metadata: metadata != other.metadata         }     }++    /// Returns a copy with `field` taken from `other`.+    func replacing(_ field: TaskEditField, withValueFrom other: TaskEditSnapshot) -> TaskEditSnapshot {+        var copy = self+        switch field {+        case .name: copy.name = other.name+        case .description: copy.description = other.description+        case .type: copy.type = other.type+        case .priority: copy.priority = other.priority+        case .status: copy.status = other.status+        case .project: copy.projectID = other.projectID+        case .milestone: copy.milestoneID = other.milestoneID+        case .metadata: copy.metadata = other.metadata+        }+        return copy+    } }  // MARK: - Merge
Transit/Transit/Services/ProjectEditMerge.swift Modified +22 / -13
diff --git a/Transit/Transit/Services/ProjectEditMerge.swift b/Transit/Transit/Services/ProjectEditMerge.swiftindex 696bf2f..64c46c3 100644--- a/Transit/Transit/Services/ProjectEditMerge.swift+++ b/Transit/Transit/Services/ProjectEditMerge.swift@@ -70,6 +70,18 @@ nonisolated struct ProjectEditSnapshot: EditSnapshot {         case .color: colorHex != other.colorHex         }     }++    /// Returns a copy with `field` taken from `other`.+    func replacing(_ field: ProjectEditField, withValueFrom other: ProjectEditSnapshot) -> ProjectEditSnapshot {+        var copy = self+        switch field {+        case .name: copy.name = other.name+        case .description: copy.description = other.description+        case .gitRepo: copy.gitRepo = other.gitRepo+        case .color: copy.colorHex = other.colorHex+        }+        return copy+    } }  // MARK: - Merge@@ -163,18 +175,15 @@ struct ProjectEditForm {         return ProjectEditMerge(original: original, edited: edited, live: ProjectEditSnapshot(project: project))     } -    /// Drops the user's edits to the conflicting fields in favour of the values-    /// now on the project, and re-baselines so untouched fields stay untouched-    /// and the user's other edits stay pending.-    mutating func adoptLiveValues(for merge: ProjectEditMerge, from project: Project) {-        for field in merge.conflictingFields {-            switch field {-            case .name: name = project.name-            case .description: description = project.projectDescription-            case .gitRepo: gitRepo = project.gitRepo ?? ""-            case .color: colorHex = ProjectEditSnapshot.normalizedHex(project.colorHex)-            }-        }-        original = ProjectEditSnapshot(project: project)+    /// Rebuilds the whole draft on the live snapshot shown by the resolved+    /// merge. Starting from live refreshes untouched external changes; only+    /// genuine non-conflicting user edits remain overlaid.+    mutating func adoptLiveValues(for merge: ProjectEditMerge) {+        let rebased = merge.rebasedEdited+        name = rebased.name+        description = rebased.description+        gitRepo = rebased.gitRepo+        colorHex = rebased.colorHex+        original = merge.live     } }
Transit/Transit/Services/MilestoneEditMerge.swift Modified +21 / -11
diff --git a/Transit/Transit/Services/MilestoneEditMerge.swift b/Transit/Transit/Services/MilestoneEditMerge.swiftindex ad6d151..1166020 100644--- a/Transit/Transit/Services/MilestoneEditMerge.swift+++ b/Transit/Transit/Services/MilestoneEditMerge.swift@@ -42,6 +42,19 @@ nonisolated struct MilestoneEditSnapshot: EditSnapshot {         case .description: description != other.description         }     }++    /// Returns a copy with `field` taken from `other`.+    func replacing(+        _ field: MilestoneEditField,+        withValueFrom other: MilestoneEditSnapshot+    ) -> MilestoneEditSnapshot {+        var copy = self+        switch field {+        case .name: copy.name = other.name+        case .description: copy.description = other.description+        }+        return copy+    } }  // MARK: - Merge@@ -125,16 +138,13 @@ struct MilestoneEditForm {         )     } -    /// Drops the user's edits to the conflicting fields in favour of the values-    /// now on the milestone, and re-baselines so untouched fields stay untouched-    /// and the user's other edits stay pending.-    mutating func adoptLiveValues(for merge: MilestoneEditMerge, from milestone: Milestone) {-        for field in merge.conflictingFields {-            switch field {-            case .name: name = milestone.name-            case .description: description = milestone.milestoneDescription ?? ""-            }-        }-        original = MilestoneEditSnapshot(milestone: milestone)+    /// Rebuilds the whole draft on the live snapshot shown by the resolved+    /// merge. Starting from live refreshes untouched external changes; only+    /// genuine non-conflicting user edits remain overlaid.+    mutating func adoptLiveValues(for merge: MilestoneEditMerge) {+        let rebased = merge.rebasedEdited+        name = rebased.name+        description = rebased.description+        original = merge.live     } }
Transit/Transit/Views/Shared/EditConflictAlert.swift Modified +23 / -3
diff --git a/Transit/Transit/Views/Shared/EditConflictAlert.swift b/Transit/Transit/Views/Shared/EditConflictAlert.swiftindex 34eff04..b156add 100644--- a/Transit/Transit/Views/Shared/EditConflictAlert.swift+++ b/Transit/Transit/Views/Shared/EditConflictAlert.swift@@ -28,7 +28,7 @@ extension EditMerge { struct EditConflictAlert<Snapshot: EditSnapshot>: ViewModifier {     let subject: String     @Binding var conflict: EditMerge<Snapshot>?-    let keepMine: () -> Void+    let keepMine: (EditMerge<Snapshot>) -> Void     let useTheirs: (EditMerge<Snapshot>) -> Void      func body(content: Content) -> some View {@@ -40,7 +40,7 @@ struct EditConflictAlert<Snapshot: EditSnapshot>: ViewModifier {             ),             presenting: conflict         ) { merge in-            Button("Keep My Changes", role: .destructive) { keepMine() }+            Button("Keep My Changes", role: .destructive) { keepMine(merge) }             Button("Use Updated Values", role: .cancel) { useTheirs(merge) }         } message: { merge in             Text(merge.conflictDescription(subject: subject))@@ -48,11 +48,31 @@ struct EditConflictAlert<Snapshot: EditSnapshot>: ViewModifier {     } } +/// Presents a conflict immediately, or after one yield when replacing the alert+/// currently being dismissed by SwiftUI.+@MainActor+func presentEditConflict<Snapshot: EditSnapshot>(+    _ merge: EditMerge<Snapshot>,+    in conflict: Binding<EditMerge<Snapshot>?>,+    replacingShownAlert: Bool+) {+    guard replacingShownAlert else {+        conflict.wrappedValue = merge+        return+    }++    conflict.wrappedValue = nil+    Task { @MainActor in+        await Task.yield()+        conflict.wrappedValue = merge+    }+}+ extension View {     func editConflictAlert<Snapshot: EditSnapshot>(         subject: String,         conflict: Binding<EditMerge<Snapshot>?>,-        keepMine: @escaping () -> Void,+        keepMine: @escaping (EditMerge<Snapshot>) -> Void,         useTheirs: @escaping (EditMerge<Snapshot>) -> Void     ) -> some View {         modifier(
Transit/Transit/Views/TaskDetail/TaskEditView.swift Modified +38 / -43
diff --git a/Transit/Transit/Views/TaskDetail/TaskEditView.swift b/Transit/Transit/Views/TaskDetail/TaskEditView.swiftindex d07ff4a..262e09f 100644--- a/Transit/Transit/Views/TaskDetail/TaskEditView.swift+++ b/Transit/Transit/Views/TaskDetail/TaskEditView.swift@@ -64,7 +64,7 @@ struct TaskEditView: View {         }         .taskEditConflictAlert(             conflict: $pendingConflict,-            keepMine: { save(overwritingConflicts: true) },+            keepMine: { save(consentingTo: $0) },             useTheirs: { adoptLiveValues(for: $0) }         )     }@@ -321,45 +321,37 @@ extension TaskEditView {         )     } -    /// Persists the user's edits.-    ///-    /// Only fields the user actually changed are written, so a concurrent MCP or-    /// CloudKit write to a *different* field survives (T-1798). When both sides-    /// changed the *same* field the save stops and asks;-    /// `overwritingConflicts` carries the user's answer back in.-    fileprivate func save(overwritingConflicts: Bool = false) {-        guard let originalSnapshot else { return }--        let edited = editedSnapshot()-        guard !edited.name.isEmpty else { return }--        let merge = TaskEditMerge(+    fileprivate func currentMerge() -> TaskEditMerge? {+        guard let originalSnapshot else { return nil }+        return TaskEditMerge(             original: originalSnapshot,-            edited: edited,+            edited: editedSnapshot(),             live: TaskEditSnapshot(task: task)         )+    } -        // Nothing to write. Saving anyway is exactly how the stale form used to-        // revert other writers' changes.+    /// Saves only when any conflict consent still matches the shown values.+    fileprivate func save(consentingTo shownConflict: TaskEditMerge? = nil) {+        guard let merge = currentMerge(), !merge.edited.name.isEmpty else { return }         guard merge.hasChanges else {             dismissAll()             return         } -        if merge.hasConflicts, !overwritingConflicts {-            pendingConflict = merge-            return+        if merge.hasConflicts {+            guard let shownConflict, merge.hasSameConflictSnapshot(as: shownConflict) else {+                presentEditConflict(merge, in: $pendingConflict, replacingShownAlert: shownConflict != nil)+                return+            }         } +        pendingConflict = nil         do {-            // Every mutation defers persistence. The single save inside-            // `saveOrRollback` makes the edit atomic — all of it lands or none-            // of it does.             try modelContext.saveOrRollback {                 let applier = TaskEditApplier(taskService: taskService, milestoneService: milestoneService)                 try applier.apply(                     merge,-                    edited: edited,+                    edited: merge.edited,                     to: task,                     project: selectedProject,                     milestone: selectedMilestone@@ -371,27 +363,30 @@ extension TaskEditView {         }     } -    /// Drops the user's edits to the conflicting fields in favour of the values-    /// now on the task, and re-baselines so untouched fields stay untouched and-    /// the user's other edits stay pending.-    ///-    /// The editor deliberately stays open and nothing is saved: the point is to-    /// show the user what the other writer did before they commit to anything.-    fileprivate func adoptLiveValues(for merge: TaskEditMerge) {-        for field in merge.conflictingFields {-            switch field {-            case .name: name = task.name-            case .description: taskDescription = task.taskDescription ?? ""-            case .type: selectedType = task.type-            case .priority: selectedPriority = task.priority-            case .status: selectedStatus = task.status-            case .project: selectedProjectID = task.project?.id-            case .milestone: selectedMilestone = task.milestone-            case .metadata: metadata = task.metadata-            }+    fileprivate func adoptLiveValues(for shownConflict: TaskEditMerge) {+        guard let merge = currentMerge() else { return }+        guard !merge.hasConflicts || merge.hasSameConflictSnapshot(as: shownConflict) else {+            presentEditConflict(merge, in: $pendingConflict, replacingShownAlert: true)+            return         } -        originalSnapshot = TaskEditSnapshot(task: task)+        let rebased = merge.rebasedEdited+        // The rebased ID can only originate from the edited selection or live+        // task, and is dropped when it does not belong to the rebased project.+        let rebasedMilestone = Self.rebasedMilestone(+            milestoneID: rebased.milestoneID,+            projectID: rebased.projectID,+            candidates: [selectedMilestone, task.milestone]+        )+        name = rebased.name+        taskDescription = rebased.description+        selectedType = rebased.type+        selectedPriority = rebased.priority+        selectedStatus = rebased.status+        selectedProjectID = rebased.projectID+        selectedMilestone = rebasedMilestone+        metadata = rebased.metadata+        originalSnapshot = merge.live         pendingConflict = nil     } }
Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift Modified (review fix, 5b7def6) +23 / -0
diff --git a/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift b/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swiftindex dd76f2e..f9845a8 100644--- a/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift+++ b/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift@@ -26,4 +26,27 @@ extension TaskEditView {          return milestones     }++    /// The milestone a rebased draft should select, or `nil` when none fits.+    ///+    /// A rebase merges each field independently, so an external project move can+    /// land alongside a preserved milestone edit made against the *old* project.+    /// Decision 6 — moving project clears the milestone — settles that pairing:+    /// a milestone from another project is dropped rather than carried into a+    /// draft whose next save `MilestoneService.setMilestone` would reject as a+    /// project mismatch, leaving the editor unable to save at all.+    ///+    /// `candidates` are the only milestones a rebased ID can name: the user's+    /// current selection and the one now on the task.+    static func rebasedMilestone(+        milestoneID: UUID?,+        projectID: UUID?,+        candidates: [Milestone?]+    ) -> Milestone? {+        guard let milestoneID else { return nil }++        return candidates+            .compactMap { $0 }+            .first { $0.id == milestoneID && $0.project?.id == projectID }+    } }
Transit/Transit/Views/TaskDetail/TaskEditConflictAlert.swift Modified +1 / -1
diff --git a/Transit/Transit/Views/TaskDetail/TaskEditConflictAlert.swift b/Transit/Transit/Views/TaskDetail/TaskEditConflictAlert.swiftindex 87d493a..711d4d8 100644--- a/Transit/Transit/Views/TaskDetail/TaskEditConflictAlert.swift+++ b/Transit/Transit/Views/TaskDetail/TaskEditConflictAlert.swift@@ -13,7 +13,7 @@ extension View {     /// shared with the project and milestone editors (T-1798, T-1817).     func taskEditConflictAlert(         conflict: Binding<TaskEditMerge?>,-        keepMine: @escaping () -> Void,+        keepMine: @escaping (TaskEditMerge) -> Void,         useTheirs: @escaping (TaskEditMerge) -> Void     ) -> some View {         editConflictAlert(
Transit/Transit/Views/Settings/ProjectEditView.swift Modified +22 / -19
diff --git a/Transit/Transit/Views/Settings/ProjectEditView.swift b/Transit/Transit/Views/Settings/ProjectEditView.swiftindex bda3d28..7da1191 100644--- a/Transit/Transit/Views/Settings/ProjectEditView.swift+++ b/Transit/Transit/Views/Settings/ProjectEditView.swift@@ -29,7 +29,7 @@ struct ProjectEditView: View {             .editConflictAlert(                 subject: "Project",                 conflict: $pendingConflict,-                keepMine: { saveExisting(overwritingConflicts: true) },+                keepMine: { saveExisting(consentingTo: $0) },                 useTheirs: { adoptLiveValues(for: $0) }             )     }@@ -178,13 +178,9 @@ extension ProjectEditView {         }     } -    /// Persists the user's edits to an existing project.-    ///-    /// Only fields the user actually changed are written, so a concurrent MCP or-    /// CloudKit write to a *different* field survives (T-1817). When both sides-    /// changed the *same* field the save stops and asks; `overwritingConflicts`-    /// carries the user's answer back in.-    fileprivate func saveExisting(overwritingConflicts: Bool = false) {+    /// Persists the user's edits to an existing project. Consent, when present,+    /// applies only to the exact conflict field values shown by that alert.+    fileprivate func saveExisting(consentingTo shownConflict: ProjectEditMerge? = nil) {         guard let project, let merge = form.merge(against: project) else { return }          // Nothing to write. Saving anyway is exactly how the stale form used to@@ -194,14 +190,17 @@ extension ProjectEditView {             return         } -        if merge.hasConflicts, !overwritingConflicts {-            pendingConflict = merge-            return+        if merge.hasConflicts {+            guard let shownConflict, merge.hasSameConflictSnapshot(as: shownConflict) else {+                presentEditConflict(merge, in: $pendingConflict, replacingShownAlert: shownConflict != nil)+                return+            }         } +        pendingConflict = nil         do {             let applier = ProjectEditApplier(projectService: projectService)-            try applier.apply(merge, edited: form.edited, to: project)+            try applier.apply(merge, edited: merge.edited, to: project)         } catch ProjectMutationError.invalidName {             errorMessage = "Project name cannot be empty."             return@@ -240,13 +239,17 @@ extension ProjectEditView {         dismiss()     } -    /// Loads the external values for the conflicting fields and re-baselines.-    ///-    /// The editor deliberately stays open and nothing is saved: the point is to-    /// show the user what the other writer did before they commit to anything.-    fileprivate func adoptLiveValues(for merge: ProjectEditMerge) {-        guard let project else { return }-        form.adoptLiveValues(for: merge, from: project)+    /// Recomputes before using external values. Changed conflict fields or+    /// values cause a new alert; otherwise the form performs a complete safe+    /// rebase and remains open for review.+    fileprivate func adoptLiveValues(for shownConflict: ProjectEditMerge) {+        guard let project, let merge = form.merge(against: project) else { return }+        guard !merge.hasConflicts || merge.hasSameConflictSnapshot(as: shownConflict) else {+            presentEditConflict(merge, in: $pendingConflict, replacingShownAlert: true)+            return+        }++        form.adoptLiveValues(for: merge)         pendingConflict = nil     } }
Transit/Transit/Views/Settings/MilestoneEditView.swift Modified +22 / -19
diff --git a/Transit/Transit/Views/Settings/MilestoneEditView.swift b/Transit/Transit/Views/Settings/MilestoneEditView.swiftindex 11dfa88..b1561b7 100644--- a/Transit/Transit/Views/Settings/MilestoneEditView.swift+++ b/Transit/Transit/Views/Settings/MilestoneEditView.swift@@ -29,7 +29,7 @@ struct MilestoneEditView: View {             .editConflictAlert(                 subject: "Milestone",                 conflict: $pendingConflict,-                keepMine: { saveExisting(overwritingConflicts: true) },+                keepMine: { saveExisting(consentingTo: $0) },                 useTheirs: { adoptLiveValues(for: $0) }             )     }@@ -135,13 +135,9 @@ struct MilestoneEditView: View {         }     } -    /// Persists the user's edits to an existing milestone.-    ///-    /// Only fields the user actually changed are written, so a concurrent MCP or-    /// CloudKit write to a *different* field survives (T-1817). When both sides-    /// changed the *same* field the save stops and asks; `overwritingConflicts`-    /// carries the user's answer back in.-    private func saveExisting(overwritingConflicts: Bool = false) {+    /// Persists the user's edits to an existing milestone. Consent, when+    /// present, applies only to the exact conflict field values shown.+    private func saveExisting(consentingTo shownConflict: MilestoneEditMerge? = nil) {         guard let milestone, let merge = form.merge(against: milestone) else { return }          // Nothing to write. Saving anyway is exactly how the stale form used to@@ -151,14 +147,17 @@ struct MilestoneEditView: View {             return         } -        if merge.hasConflicts, !overwritingConflicts {-            pendingConflict = merge-            return+        if merge.hasConflicts {+            guard let shownConflict, merge.hasSameConflictSnapshot(as: shownConflict) else {+                presentEditConflict(merge, in: $pendingConflict, replacingShownAlert: shownConflict != nil)+                return+            }         } +        pendingConflict = nil         do {             let applier = MilestoneEditApplier(milestoneService: milestoneService)-            try applier.apply(merge, edited: form.edited, to: milestone)+            try applier.apply(merge, edited: merge.edited, to: milestone)         } catch {             errorMessage = error.localizedDescription             return@@ -185,13 +184,17 @@ struct MilestoneEditView: View {         }     } -    /// Loads the external values for the conflicting fields and re-baselines.-    ///-    /// The editor deliberately stays open and nothing is saved: the point is to-    /// show the user what the other writer did before they commit to anything.-    private func adoptLiveValues(for merge: MilestoneEditMerge) {-        guard let milestone else { return }-        form.adoptLiveValues(for: merge, from: milestone)+    /// Recomputes before using external values. Changed conflict fields or+    /// values cause a new alert; otherwise the form performs a complete safe+    /// rebase and remains open for review.+    private func adoptLiveValues(for shownConflict: MilestoneEditMerge) {+        guard let milestone, let merge = form.merge(against: milestone) else { return }+        guard !merge.hasConflicts || merge.hasSameConflictSnapshot(as: shownConflict) else {+            presentEditConflict(merge, in: $pendingConflict, replacingShownAlert: true)+            return+        }++        form.adoptLiveValues(for: merge)         pendingConflict = nil     } }
Transit/TransitTests/TaskEditConcurrentUpdateTests.swift Modified +60 / -0
diff --git a/Transit/TransitTests/TaskEditConcurrentUpdateTests.swift b/Transit/TransitTests/TaskEditConcurrentUpdateTests.swiftindex 9e7fdbe..68e5fd8 100644--- a/Transit/TransitTests/TaskEditConcurrentUpdateTests.swift+++ b/Transit/TransitTests/TaskEditConcurrentUpdateTests.swift@@ -290,6 +290,66 @@ struct TaskEditConflictDetectionTests {         #expect(merge.conflictingFieldNames == ["Name", "Type", "Status"])     } +    /// "Use Updated Values" rebuilds the whole draft from the latest task,+    /// preserving only genuine non-conflicting user edits. Untouched external+    /// changes must not remain stale and become accidental edits after rebase.+    @Test func adoptingLiveValuesRefreshesUntouchedFieldsAndKeepsCleanEdits() async throws {+        let env = try TaskEditTestEnv.make()+        let task = try await env.makeTask()+        let baseline = TaskEditSnapshot(task: task)++        try env.taskService.updateTask(task, name: "Renamed by MCP", type: .bug)+        try env.taskService.updateStatus(task: task, to: .done)++        var edited = baseline+        edited.name = "Renamed by the user"+        edited.priority = .high++        let merge = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))+        let rebased = merge.rebasedEdited++        #expect(rebased.name == "Renamed by MCP")+        #expect(rebased.type == .bug)+        #expect(rebased.status == .done)+        #expect(rebased.priority == .high)++        let afterRebase = TaskEditMerge(original: merge.live, edited: rebased, live: merge.live)+        #expect(afterRebase.changedFields == [.priority])+        #expect(afterRebase.hasConflicts == false)+    }++    /// Keep-mine consent applies only to the exact conflict values shown. If an+    /// external writer changes a shown value and introduces another conflict+    /// while the alert is open, the current merge must require a new alert.+    @Test func changedConflictSnapshotInvalidatesAlertConsent() async throws {+        let env = try TaskEditTestEnv.make()+        let task = try await env.makeTask()+        let baseline = TaskEditSnapshot(task: task)++        var edited = baseline+        edited.name = "Renamed by the user"+        edited.status = .planning++        try env.taskService.updateTask(task, name: "First MCP name")+        let shown = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))+        #expect(shown.conflictingFields == [.name])++        try env.taskService.updateTask(task, name: "Second MCP name")+        let changedValue = TaskEditMerge(+            original: baseline,+            edited: edited,+            live: TaskEditSnapshot(task: task)+        )+        #expect(changedValue.conflictingFields == [.name])+        #expect(changedValue.hasSameConflictSnapshot(as: shown) == false)++        try env.taskService.updateStatus(task: task, to: .done)+        let current = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))++        #expect(current.conflictingFields == [.name, .status])+        #expect(current.hasSameConflictSnapshot(as: shown) == false)+    }+     /// Metadata is compared by value, so an external metadata write conflicts     /// with a user metadata edit rather than being silently overwritten.     @Test func metadataConflictIsDetected() async throws {
Transit/TransitTests/TaskEditConflictConsentTests.swift Added (review fix, 5b7def6) +56 / -0
diff --git a/Transit/TransitTests/TaskEditConflictConsentTests.swift b/Transit/TransitTests/TaskEditConflictConsentTests.swiftnew file mode 100644index 0000000..c40af56--- /dev/null+++ b/Transit/TransitTests/TaskEditConflictConsentTests.swift@@ -0,0 +1,56 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// Regression tests for the T-1935 consent scoping in `EditMerge`.+///+/// `TaskEditConcurrentUpdateTests` covers the cases where a changed conflict+/// snapshot *revokes* the user's answer. These cover the other half: an answer+/// that is still valid must survive, otherwise a conflict alert could never be+/// dismissed and no save behind one could ever complete.+@MainActor @Suite(.serialized)+struct TaskEditConflictConsentTests {++    /// An unchanged conflict still matches, and an external write to a field the+    /// user never touched does not revoke the answer. Without this, always+    /// returning `false` would look correct while making every conflict alert+    /// impossible to get past.+    @Test func unchangedConflictSnapshotKeepsAlertConsent() async throws {+        let env = try TaskEditTestEnv.make()+        let task = try await env.makeTask()+        let baseline = TaskEditSnapshot(task: task)++        var edited = baseline+        edited.name = "Renamed by the user"++        try env.taskService.updateTask(task, name: "Renamed by MCP")+        let shown = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))+        #expect(shown.conflictingFields == [.name])+        #expect(shown.hasSameConflictSnapshot(as: shown))++        // An untouched field changing externally is not part of the conflict the+        // user answered, so it must not invalidate their choice.+        try env.taskService.updateTask(task, description: "Rewritten by MCP")+        let current = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))++        #expect(current.conflictingFields == [.name])+        #expect(current.hasSameConflictSnapshot(as: shown))+    }++    /// A conflict-free merge trivially matches itself, so "Use Updated Values"+    /// on an already-resolved merge rebases instead of re-alerting forever.+    @Test func conflictFreeMergeMatchesItself() async throws {+        let env = try TaskEditTestEnv.make()+        let task = try await env.makeTask()+        let baseline = TaskEditSnapshot(task: task)++        var edited = baseline+        edited.priority = .high++        let merge = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))++        #expect(merge.hasConflicts == false)+        #expect(merge.hasSameConflictSnapshot(as: merge))+    }+}
Transit/TransitTests/TaskEditViewMilestoneTests.swift Modified (review fix, 5b7def6) +83 / -0
diff --git a/Transit/TransitTests/TaskEditViewMilestoneTests.swift b/Transit/TransitTests/TaskEditViewMilestoneTests.swiftindex b1ecc98..62a1d91 100644--- a/Transit/TransitTests/TaskEditViewMilestoneTests.swift+++ b/Transit/TransitTests/TaskEditViewMilestoneTests.swift@@ -74,4 +74,87 @@ struct TaskEditViewMilestoneTests {          #expect(availableMilestones.map(\.id) == [openMilestone.id])     }++    /// A rebase merges fields independently, so "Use Updated Values" can pair an+    /// externally moved project with a milestone the user picked under the old+    /// one. Carrying that pair into the draft makes every later save fail+    /// `MilestoneService` project-match validation, so the milestone is dropped.+    @Test func rebasedMilestoneIsDroppedWhenItBelongsToAnotherProject() async throws {+        let (milestoneService, context) = try makeMilestoneService()+        let originalProject = makeProject(in: context, name: "Original")+        let externalProject = makeProject(in: context, name: "Moved elsewhere")+        let userPick = try await milestoneService.createMilestone(+            name: "Picked by the user",+            description: nil,+            project: originalProject+        )++        let rebased = TaskEditView.rebasedMilestone(+            milestoneID: userPick.id,+            projectID: externalProject.id,+            candidates: [userPick, nil]+        )++        #expect(rebased == nil)+    }++    /// The ordinary case: the rebased milestone still belongs to the rebased+    /// project, so the user's non-conflicting pick survives the rebase.+    @Test func rebasedMilestoneIsKeptWhenItMatchesTheRebasedProject() async throws {+        let (milestoneService, context) = try makeMilestoneService()+        let project = makeProject(in: context)+        let userPick = try await milestoneService.createMilestone(+            name: "Picked by the user",+            description: nil,+            project: project+        )++        let rebased = TaskEditView.rebasedMilestone(+            milestoneID: userPick.id,+            projectID: project.id,+            candidates: [userPick, nil]+        )++        #expect(rebased?.id == userPick.id)+    }++    /// An externally assigned milestone is resolved from the live task when the+    /// user never picked one.+    @Test func rebasedMilestoneResolvesTheLiveTaskMilestone() async throws {+        let (milestoneService, context) = try makeMilestoneService()+        let project = makeProject(in: context)+        let externalPick = try await milestoneService.createMilestone(+            name: "Assigned by MCP",+            description: nil,+            project: project+        )++        let rebased = TaskEditView.rebasedMilestone(+            milestoneID: externalPick.id,+            projectID: project.id,+            candidates: [nil, externalPick]+        )++        #expect(rebased?.id == externalPick.id)+    }++    /// No milestone in the rebased draft means no selection, regardless of what+    /// either side had before.+    @Test func rebasedMilestoneIsNilWhenTheDraftHasNoMilestone() async throws {+        let (milestoneService, context) = try makeMilestoneService()+        let project = makeProject(in: context)+        let candidate = try await milestoneService.createMilestone(+            name: "Previously selected",+            description: nil,+            project: project+        )++        let rebased = TaskEditView.rebasedMilestone(+            milestoneID: nil,+            projectID: project.id,+            candidates: [candidate, nil]+        )++        #expect(rebased == nil)+    } }
Transit/TransitTests/ProjectEditConflictDetectionTests.swift Modified +78 / -1
diff --git a/Transit/TransitTests/ProjectEditConflictDetectionTests.swift b/Transit/TransitTests/ProjectEditConflictDetectionTests.swiftindex 2c1f7dc..5b395f4 100644--- a/Transit/TransitTests/ProjectEditConflictDetectionTests.swift+++ b/Transit/TransitTests/ProjectEditConflictDetectionTests.swift@@ -97,7 +97,7 @@ struct ProjectEditConflictDetectionTests {         form.colorHex = "00FF00"          let merge = try #require(form.merge(against: project))-        form.adoptLiveValues(for: merge, from: project)+        form.adoptLiveValues(for: merge)          #expect(form.name == "Renamed elsewhere")         #expect(form.colorHex == "00FF00")@@ -107,6 +107,83 @@ struct ProjectEditConflictDetectionTests {         #expect(rebased.hasConflicts == false)     } +    /// "Use Updated Values" also refreshes untouched fields changed externally.+    /// Only the user's genuine non-conflicting color edit remains pending after+    /// the draft is rebased onto all current project values.+    @Test func adoptingLiveValuesRefreshesUntouchedExternalFields() throws {+        let env = try ProjectEditTestEnv.make()+        let project = try env.makeProject()+        var form = env.loadedForm(for: project)++        try env.projectService.updateProject(+            project,+            name: "Renamed elsewhere",+            description: "Rewritten elsewhere",+            gitRepo: "https://example.com/external.git",+            colorHex: project.colorHex+        )++        form.name = "Renamed by the user"+        form.colorHex = "00FF00"++        let merge = try #require(form.merge(against: project))+        form.adoptLiveValues(for: merge)++        #expect(form.name == "Renamed elsewhere")+        #expect(form.description == "Rewritten elsewhere")+        #expect(form.gitRepo == "https://example.com/external.git")+        #expect(form.colorHex == "00FF00")++        let rebased = try #require(form.merge(against: project))+        #expect(rebased.changedFields == [.color])+        #expect(rebased.hasConflicts == false)+    }++    /// Alert consent is tied to the exact conflict field values displayed. A+    /// changed shown value plus a newly conflicting field must invalidate the+    /// old choice so the editor presents the current conflicts instead.+    @Test func changedConflictSnapshotInvalidatesAlertConsent() throws {+        let env = try ProjectEditTestEnv.make()+        let project = try env.makeProject()+        var form = env.loadedForm(for: project)++        form.name = "Renamed by the user"+        form.colorHex = "00FF00"++        try env.projectService.updateProject(+            project,+            name: "First external name",+            description: project.projectDescription,+            gitRepo: project.gitRepo,+            colorHex: project.colorHex+        )+        let shown = try #require(form.merge(against: project))+        #expect(shown.conflictingFields == [.name])++        try env.projectService.updateProject(+            project,+            name: "Second external name",+            description: project.projectDescription,+            gitRepo: project.gitRepo,+            colorHex: project.colorHex+        )+        let changedValue = try #require(form.merge(against: project))+        #expect(changedValue.conflictingFields == [.name])+        #expect(changedValue.hasSameConflictSnapshot(as: shown) == false)++        try env.projectService.updateProject(+            project,+            name: project.name,+            description: project.projectDescription,+            gitRepo: project.gitRepo,+            colorHex: "0000FF"+        )+        let current = try #require(form.merge(against: project))++        #expect(current.conflictingFields == [.name, .color])+        #expect(current.hasSameConflictSnapshot(as: shown) == false)+    }+     /// The alert names the fields and both choices, so the user is not asked a     /// blind question.     @Test func conflictDescriptionNamesFieldsAndChoices() throws {
Transit/TransitTests/MilestoneEditConcurrentUpdateTests.swift Modified +58 / -1
diff --git a/Transit/TransitTests/MilestoneEditConcurrentUpdateTests.swift b/Transit/TransitTests/MilestoneEditConcurrentUpdateTests.swiftindex 5f99022..fdc1caa 100644--- a/Transit/TransitTests/MilestoneEditConcurrentUpdateTests.swift+++ b/Transit/TransitTests/MilestoneEditConcurrentUpdateTests.swift@@ -219,7 +219,7 @@ struct MilestoneEditConflictDetectionTests {         form.description = "Rewritten by the user"          let merge = try #require(form.merge(against: milestone))-        form.adoptLiveValues(for: merge, from: milestone)+        form.adoptLiveValues(for: merge)          #expect(form.name == "Renamed elsewhere")         #expect(form.description == "Rewritten by the user")@@ -229,6 +229,63 @@ struct MilestoneEditConflictDetectionTests {         #expect(rebased.hasConflicts == false)     } +    /// "Use Updated Values" refreshes every field the user did not genuinely+    /// edit. An external description change alongside the name conflict must be+    /// visible in the rebased form rather than becoming a stale user edit.+    @Test func adoptingLiveValuesRefreshesUntouchedExternalField() async throws {+        let env = try MilestoneEditTestEnv.make()+        let milestone = try await env.makeMilestone()+        var form = env.loadedForm(for: milestone)++        try env.milestoneService.updateMilestone(+            milestone,+            name: "Renamed elsewhere",+            description: "Rewritten elsewhere"+        )+        form.name = "Renamed by the user"++        let merge = try #require(form.merge(against: milestone))+        form.adoptLiveValues(for: merge)++        #expect(form.name == "Renamed elsewhere")+        #expect(form.description == "Rewritten elsewhere")++        let rebased = try #require(form.merge(against: milestone))+        #expect(rebased.hasChanges == false)+        #expect(rebased.hasConflicts == false)+    }++    /// Alert consent is scoped to the exact shown conflict snapshot. If a+    /// second writer changes the shown name again and also conflicts on the+    /// description while the alert is open, the old consent is invalid.+    @Test func changedConflictSnapshotInvalidatesAlertConsent() async throws {+        let env = try MilestoneEditTestEnv.make()+        let milestone = try await env.makeMilestone()+        var form = env.loadedForm(for: milestone)++        form.name = "Renamed by the user"+        form.description = "Rewritten by the user"++        try env.milestoneService.updateMilestone(milestone, name: "First external name", description: nil)+        let shown = try #require(form.merge(against: milestone))+        #expect(shown.conflictingFields == [.name])++        try env.milestoneService.updateMilestone(milestone, name: "Second external name", description: nil)+        let changedValue = try #require(form.merge(against: milestone))+        #expect(changedValue.conflictingFields == [.name])+        #expect(changedValue.hasSameConflictSnapshot(as: shown) == false)++        try env.milestoneService.updateMilestone(+            milestone,+            name: nil,+            description: "Rewritten elsewhere"+        )+        let current = try #require(form.merge(against: milestone))++        #expect(current.conflictingFields == [.name, .description])+        #expect(current.hasSameConflictSnapshot(as: shown) == false)+    }+     /// The alert names the fields and both choices.     @Test func conflictDescriptionNamesFieldsAndChoices() async throws {         let env = try MilestoneEditTestEnv.make()
specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/report.md Added +139 / -0
diff --git a/specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/report.md b/specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/report.mdnew file mode 100644index 0000000..3094acc--- /dev/null+++ b/specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/report.md@@ -0,0 +1,139 @@+# Bugfix Report: Editor Conflict Choices Can Overwrite Unseen External Changes++**Date:** 2026-08-01+**Status:** Fixed+**Ticket:** T-1935++## Description of the Issue++The task, project, and milestone editors use a three-way merge to detect concurrent edits, but both conflict-resolution choices can still lose changes.++`Use Updated Values` copies only the fields that conflicted and then replaces the baseline with every current live value. Any untouched form field changed externally remains stale; after the baseline reset, the next save misclassifies that stale value as a user edit and writes it over the external change.++`Keep My Changes` recomputes the merge after the alert but authorizes every conflict in that recomputed merge with one Boolean. If another MCP, CloudKit, or window update changes a shown conflict or adds a new conflict while the alert is open, the save overwrites values the user was never shown.++**Reproduction steps:**+1. Open an editor and change one field locally.+2. Change that same field and an untouched field through another writer.+3. Save and choose **Use Updated Values**; observe that only the conflicting field refreshes.+4. Save again; observe that the stale untouched form value overwrites the external value.+5. Alternatively, while the first conflict alert is open, change a shown value again or create another same-field conflict externally.+6. Choose **Keep My Changes**; observe that the newly changed conflict is overwritten without a new alert.++**Impact:** High. All three editors can silently discard MCP, CloudKit, or second-window changes during the conflict flow that is intended to prevent lost updates.++## Investigation Summary++The existing T-1798 and T-1817/T-1880 reports, shared merge implementation, all three editor save paths, alert modifier, form rebasing methods, and editor merge regression suites were inspected.++- **Symptoms examined:** stale untouched fields after rebasing; conflict consent surviving changes to the alert's live state; multi-field changes across all editors.+- **Code inspected:** `Services/EditMerge.swift`, all three editor-specific merge files, all three editor views, `Views/Shared/EditConflictAlert.swift`, and the task/project/milestone concurrent-update suites.+- **Hypotheses tested:** Separate SwiftData contexts were ruled out because UI and MCP deliberately share `mainContext`. Save atomicity was ruled out because the loss occurs when selecting which values to apply, before persistence. Main-actor concurrency does not prevent the issue because the external write can run between alert presentation and button handling.++### Systematic inspection findings++1. **Data flow — incomplete rebase:** `ProjectEditForm.adoptLiveValues`, `MilestoneEditForm.adoptLiveValues`, and `TaskEditView.adoptLiveValues` copy only `conflictingFields`, then replace the full baseline with the live snapshot.+2. **State management — stale draft:** fields not edited by the user are not refreshed from live values, so they differ from the new baseline and become false user edits.+3. **Race/consent scope:** editor save methods accept `overwritingConflicts: Bool`; this carries no identity for the fields or values the user actually reviewed.+4. **Alert API:** the alert passes the shown merge to **Use Updated Values** but not to **Keep My Changes**, preventing exact-snapshot validation for one branch.+5. **Merge representation:** `EditMerge` retains only changed/conflicting field sets, not the original, edited, and live snapshots needed to validate consent or derive a correct rebase.++## Discovered Root Cause++The conflict flow treats both rebasing and overwrite consent as field-set operations when they are snapshot operations.++**Five Whys:**+1. Why can a second save overwrite an unseen external value? Because an untouched form field remains stale after **Use Updated Values**.+2. Why does it become writable? Because the baseline advances to live while the form does not, making the stale value appear user-edited.+3. Why is the form not fully rebuilt? Because adoption copies only fields classified as conflicts.+4. Why can **Keep My Changes** overwrite a later conflict? Because a Boolean suppresses conflict checks on a newly recomputed merge.+5. Why is the Boolean insufficient? Because the merge does not preserve the exact edited/live conflict snapshot shown to the user.++**Defect type:** Lost update caused by stale state rebasing and incorrectly scoped race-sensitive consent.++**Why it occurred:** T-1798/T-1817 added field-level conflict choices but encoded only which fields conflicted. The follow-up actions need the values shown at the decision point and a full-draft rebase policy, neither of which was represented.++**Contributing factors:** all actions run synchronously on `MainActor`, which makes each individual action atomic but does not prevent writes while a modal alert waits for input; project and milestone forms had testable rebase helpers, while task draft state remained embedded in the view.++## Resolution for the Issue++`EditMerge` now retains the original, edited, and live snapshots. Its shared `rebasedEdited` value starts from the complete live snapshot and overlays only user-changed, non-conflicting fields. **Use Updated Values** therefore refreshes every untouched or conflicting field while preserving only genuine non-conflicting local edits.++`hasSameConflictSnapshot(as:)` scopes consent to the exact conflict field set and original, edited, and live values shown in the alert. Both alert callbacks now receive that shown merge. Task, project, and milestone editors recompute immediately before either action; they proceed only if the current conflicts still match, otherwise they dismiss and re-present the current conflict snapshot after yielding to SwiftUI's alert lifecycle. The former `overwritingConflicts: Bool` bypass was removed.++Task, project, and milestone snapshots implement per-field replacement for shared rebasing. Each editor adopts the entire rebased draft and advances its baseline to the current live snapshot. Task milestone rebasing resolves the rebased milestone ID from the selected and current-live milestone candidates.++Because a rebase merges each field independently, it can pair an externally moved project with a milestone the user picked under the old project — a combination `MilestoneService.setMilestone` rejects, which would leave the editor showing a blank milestone picker and failing every subsequent save with the generic error. `TaskEditView.rebasedMilestone(milestoneID:projectID:candidates:)` therefore applies Decision 6 (moving project clears the milestone) to the rebased draft and drops a candidate that does not belong to the rebased project.++**Approach rationale:** Keeping snapshot identity and rebase policy in the shared merge layer gives all editors identical behavior and makes both race and rebase invariants directly testable.++**Alternatives considered:**+- Refresh only known untouched fields in each view — rejected because three hand-written policies would drift and still lack snapshot consent.+- Disable external writes while an alert is shown — rejected because MCP/CloudKit cannot be safely paused and cross-device writes are inherently concurrent.+- Accept consent for matching field names only — rejected because a second external value for the same field was not shown to the user.++## Regression Test++**Test files:**+- `Transit/TransitTests/TaskEditConcurrentUpdateTests.swift`+- `Transit/TransitTests/ProjectEditConflictDetectionTests.swift`+- `Transit/TransitTests/MilestoneEditConcurrentUpdateTests.swift`++**Tests:** Each editor has a multi-field rebase regression and an alert-race regression. They verify that untouched live fields refresh, genuine non-conflicting user edits remain pending, and both a changed value for the same conflict field set and a newly added conflict invalidate the shown alert snapshot.++**Red phase:** compile-only. `make test-quick` failed building `ProjectEditConflictDetectionTests.swift` because `ProjectEditMerge` had no `hasSameConflictSnapshot(as:)` member, and the rebase tests used the new one-argument `adoptLiveValues(for:)` signature. This confirmed the API was absent but is not behavioural evidence: no test in this change can fail against the pre-fix implementation for a behavioural reason, because the pre-fix implementation cannot compile them. The project-mismatch rebase tests added during pre-push review are the exception — they fail behaviourally without the `rebasedMilestone` project guard.++**Green command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/EditMerge.swift` | Retain snapshots and provide shared safe rebasing and exact consent validation |+| `Transit/Transit/Services/TaskEditMerge.swift` | Add task snapshot field replacement for shared rebasing |+| `Transit/Transit/Services/ProjectEditMerge.swift` | Add project field replacement and complete-form rebase |+| `Transit/Transit/Services/MilestoneEditMerge.swift` | Add milestone field replacement and complete-form rebase |+| `Transit/Transit/Views/Shared/EditConflictAlert.swift` | Pass the shown merge to both alert actions; add the shared `presentEditConflict` dismiss/yield/re-present helper |+| `Transit/Transit/Views/TaskDetail/TaskEditConflictAlert.swift` | Forward the shown task merge to keep-mine handling |+| `Transit/Transit/Views/TaskDetail/TaskEditView.swift` | Recompute and validate both choices; adopt the complete rebased task draft |+| `Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift` | Resolve the rebased milestone, dropping one that does not belong to the rebased project |+| `Transit/TransitTests/TaskEditViewMilestoneTests.swift` | Add rebased-milestone project-consistency regressions |+| `Transit/TransitTests/TaskEditConflictConsentTests.swift` | Assert consent survives when the shown conflict is unchanged |+| `CLAUDE.md` | Document snapshot retention, consent scoping, and the new snapshot requirement |+| `Transit/Transit/Views/Settings/ProjectEditView.swift` | Recompute and validate both choices; re-alert on changed conflicts |+| `Transit/Transit/Views/Settings/MilestoneEditView.swift` | Recompute and validate both choices; re-alert on changed conflicts |+| `Transit/TransitTests/TaskEditConcurrentUpdateTests.swift` | Add task multi-field rebase and alert-race regressions |+| `Transit/TransitTests/ProjectEditConflictDetectionTests.swift` | Add project multi-field rebase and alert-race regressions |+| `Transit/TransitTests/MilestoneEditConcurrentUpdateTests.swift` | Add milestone multi-field rebase and alert-race regressions |+| `CHANGELOG.md` | Document the T-1935 fix |+| This report | Record investigation, implementation, and verification |++## Verification++**Automated:**+- [x] `make test-quick` — passed after the final regression assertions and first-round review cleanup.+- [x] `make lint` — passed, including strict SwiftLint and the SwiftData ownership guard. Shared alert-helper extraction reduced `TaskEditView.swift` to 389 lines.+- [ ] `make test` — attempted three times. The final attempt ran after competing Transit simulator jobs had cleared, built all changed code and tests, launched iPhone 17, and showed many passing tests with no test failure before the fixed 20-minute command timeout. It did not emit a final suite result and repeatedly reported DTDeviceKit failure to start `com.apple.mobile.notification_proxy` because Xcode could not establish a secure device connection.+- [ ] `make test-ui` — the target completed one result bundle before the wrapper reported exit 124: 15 test methods passed and six failed. The failures were unrelated dashboard/settings assertions, and the task-edit test failed while checking the milestone on the detail screen before opening Edit. The run also repeatedly reported the same DTDeviceKit secure-connection failure and missing LLDB debugger-version errors, so it is not a clean simulator validation.++**Manual verification:** Not performed; the merge invariants and all six task/project/milestone rebase and alert-race scenarios are exercised by the green macOS unit suite.++## Known limitation++`presentEditConflict` re-presents a replaced alert by clearing the binding and re-setting it after a single `await Task.yield()`. Nothing orders that yield against SwiftUI's own dismissal, which writes `nil` through the alert's `isPresented` setter. If the dismissal write lands after the re-set, the replacement alert is swallowed and the button appears inert.++This fails safe — nothing is written and the conflict state is discarded, not resolved — and it is self-recovering: pressing Save again finds `pendingConflict == nil`, takes the direct (non-replacing) branch, and presents the current conflict correctly. It is unverified in either direction: the path has no unit coverage (SwiftUI alert presentation ordering is not unit-testable) and neither `make test` nor `make test-ui` produced a clean simulator run. A dismissal-driven queue inside `EditConflictAlert` — promote a queued merge from the `isPresented` setter rather than after a fixed yield — would remove the timing assumption if this is ever observed in practice.++## Prevention++- Treat conflict consent as authorization for exact values, not a Boolean permission to overwrite arbitrary future conflicts.+- Rebase editable drafts by starting from the latest live snapshot and overlaying only known user-owned edits.+- Keep conflict policy in the shared merge layer so all editors remain behaviorally aligned.+- Keep same-field-value and newly-added-field alert races as separate regression assertions.+- Assert both directions of a consent predicate. Every original T-1935 assertion checked that consent was *revoked*; an implementation returning `false` unconditionally would have passed the whole suite while making conflict alerts impossible to get past.+- A field-independent rebase can produce a cross-field combination neither side ever held. Re-apply domain pairing rules (here, Decision 6) to the rebased draft.++## Related++- T-1798 — initial task editor three-way merge+- T-1817 / T-1880 — project and milestone merge/load-once forms
specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/implementation.md Added (review fix, 5b7def6) +137 / -0
diff --git a/specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/implementation.md b/specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/implementation.mdnew file mode 100644index 0000000..80a9864--- /dev/null+++ b/specs/bugfixes/editor-conflict-choices-overwrite-unseen-changes/implementation.md@@ -0,0 +1,137 @@+# Implementation Explanation: Editor Conflict Choices (T-1935)++Branch: `T-1935/bugfix-editor-conflict-choices-can-overwrite-unseen-external-changes`+Range: `origin/main...HEAD` (merge-base `ac6cef9`)++---++## Beginner Level++### What Changed++Transit can be edited from more than one place at the same time. You might have a task open in the editor while an AI agent updates the same task over the MCP server, or while CloudKit syncs a change you made on your iPad. Two writers, one record.++To handle that, the editors already did a **three-way merge**. Think of it as remembering three things:++- **original** — what the record looked like when you opened the editor+- **edited** — what is in the form right now, after your typing+- **live** — what the record looks like in the database *this instant*++Comparing those three tells the app which fields *you* changed and which fields *somebody else* changed. If you both changed the same field, that's a conflict, and the app stops and asks: **Keep My Changes** or **Use Updated Values**.++The bug was that both answers could still throw away changes you never saw.++**"Use Updated Values" was too narrow.** It only refreshed the fields that had conflicted. Say the agent renamed the task *and* rewrote its description, but you had only retyped the name. The name conflicted, so it got refreshed — but the description didn't conflict, so the form kept showing the old description. Then the app quietly declared "everything on screen is now up to date." On your next save, that stale description looked like something *you* had typed, and it overwrote the agent's version.++**"Keep My Changes" was too broad.** It set a single yes/no flag meaning "the user said overwrite." But the alert box is a modal — it can sit on screen for as long as you take to read it, and writers keep writing during that time. If the agent changed the field *again* while you were reading, or created a *second* conflict, that one flag still said "overwrite everything." You authorised overwriting values you were never shown.++The fix changes both answers to be **about specific values instead of about fields**:++- "Use Updated Values" now rebuilds the entire form from the live record, then puts back only the edits you genuinely made that nobody else touched. Nothing stale survives.+- "Keep My Changes" now remembers exactly which values the alert displayed. Before saving, it re-checks. If anything about the conflict changed, it throws away the old answer and shows you a fresh alert.++### Why It Matters++Silent data loss is the worst kind of bug, because nobody notices it until much later. The conflict alert exists specifically to prevent lost updates — it was leaking in both directions. This affects all three editors: tasks, projects, and milestones.++### Key Concepts++- **Three-way merge** — deciding what to write by comparing three versions instead of two. Git does this when merging branches; here it decides which form fields to save.+- **Snapshot** — a plain frozen copy of a record's values. Not connected to the database, so it can't change underneath you.+- **Baseline** — the "original" snapshot. Everything is measured against it, which is why advancing it incorrectly caused the bug.+- **Rebase** — rebuilding your work on top of someone else's newer version, rather than beside it.+- **Consent scoping** — an approval that covers one specific thing, rather than a blanket permission. "Yes, overwrite *this* name with *that* name" instead of "yes, overwrite."+- **Lost update** — two writers, and one silently clobbers the other.++---++## Intermediate Level++### Changes Overview++The change is concentrated in the shared merge layer, with matching updates in three editors.++**`Services/EditMerge.swift`** — the generic core.++- `EditMerge` now stores `original`, `edited`, and `live` snapshots alongside the derived `changedFields` / `conflictingFields` sets. Previously it discarded the snapshots after computing the sets, which is precisely why neither follow-up action could be implemented correctly.+- New `rebasedEdited: Snapshot` — folds `changedFields.subtracting(conflictingFields)` over `live`, overlaying each surviving user edit. Starting from `live` is the key inversion: the old code started from the form and patched conflicts, so anything not classified as a conflict stayed stale.+- New `hasSameConflictSnapshot(as:) -> Bool` — compares the conflicting field *set* and, for each field in it, the `original` / `edited` / `live` values. Field-name equality alone is insufficient: an external writer can change the same field twice while the alert is open.+- The `EditSnapshot` protocol gains `replacing(_:withValueFrom:)`, implemented per editor as a small switch.++**`Views/Shared/EditConflictAlert.swift`** — `keepMine` changes from `() -> Void` to `(EditMerge<Snapshot>) -> Void`, so both buttons now receive the merge that was actually rendered. Adds `presentEditConflict(_:in:replacingShownAlert:)`, which sets the binding directly for a fresh alert, or clears and re-sets it after a yield when replacing an alert mid-dismissal.++**The three editors** (`TaskEditView`, `ProjectEditView`, `MilestoneEditView`) — the `overwritingConflicts: Bool` parameter is replaced by `consentingTo: EditMerge?`. Both `save` and `adoptLiveValues` recompute the merge from current state before acting, and re-present the alert when the snapshot no longer matches.++### Implementation Approach++The organising insight is that both operations were modelled as **field-set** operations when they are **snapshot** operations.++Putting `rebasedEdited` and `hasSameConflictSnapshot` on the generic `EditMerge` means all three editors get identical semantics for free and both invariants become unit-testable without a view. This continues the trajectory of T-1798 → T-1817: policy migrates into the shared merge layer, editors keep only the state binding.++`adoptLiveValues` also advances the baseline to `merge.live` rather than re-reading the model. Since the form is populated from `merge.rebasedEdited`, which is itself derived from `merge.live`, form and baseline are guaranteed consistent — the old code read the model twice and could observe two different states.++### Trade-offs++- **Exact-value consent is deliberately conservative.** Any change to a shown conflict revokes the answer, even a change the user would have answered identically. The alternative — matching field names only — was rejected because a second external value for the same field was never displayed.+- **`EditMerge` grew from two `Set`s to two `Set`s plus three snapshots.** Negligible for a single-user app editing one record; it buys testability and correctness.+- **Per-field `replacing` switches are duplicated three times**, mirroring the existing `differs` switches. A keypath-based accessor could state the mapping once, at the cost of closure indirection and readability. Left as-is deliberately.+- **The `edited:` parameter on the three appliers is now redundant** — every call site passes `merge.edited`. Removing it would require touching test call sites, so it was left in place.++---++## Expert Level++### Technical Deep Dive++**Why starting from `live` is load-bearing.** The old `adoptLiveValues` computed `form ∪ {conflicting fields ← live}` and then set `baseline ← live`. For any field where `live ≠ original` and the field was *not* in `conflictingFields` (i.e. the user hadn't touched it), the form retained `original`'s value while the baseline moved to `live`. The next merge therefore classifies that field as `changed` — a phantom user edit that overwrites the external value. `rebasedEdited` inverts the construction to `live ∪ {changed \ conflicting ← edited}`, which makes the post-rebase invariant provable: for every field, form value equals either `live` (so no change is recorded) or a genuine user edit (so a change is correctly recorded).++The `reduce` over a `Set<Field>` is order-independent because each iteration writes a disjoint stored property.++**Consent scoping and the modal window.** `hasSameConflictSnapshot` compares all three snapshots, but only the `live` comparison can realistically differ: `original` is reassigned solely in `load` / `adoptLiveValues`, and `edited` derives from form state the user cannot reach behind a modal alert. The `original`/`edited` comparisons are defence-in-depth against a future non-modal presentation, not live guards.++Note what the predicate deliberately does *not* cover: an external write to a field the user never edited does not enter `conflictingFields` and so does not revoke consent. That is correct — such a field is not in `changedFields`, so the applier passes `nil` for it and the external value survives untouched.++**Cross-field consistency after a field-independent rebase.** This is the subtle failure mode, and it was found and fixed during pre-push review. `rebasedEdited` merges fields independently, so it can synthesise a combination neither side ever held. Concretely: task in project P1 with no milestone; the user picks milestone M2 (in P1) and retypes the name; externally the task is renamed differently *and* moved to P3. `.milestone` is `changed` but not `conflicting` (live milestone is still `nil` = original), so it is overlaid onto a `live` that carries project P3 — yielding project P3 paired with milestone M2 from P1.++The consequences cascade: the picker's `onChange(of: selectedProjectID)` guard is `newValue != task.project?.id`, and `newValue` *is* the live project, so the auto-clear never fires. `availableMilestones` filters M2 out (wrong project), so the picker renders blank. The next save sees `changed(.milestone)`, calls `MilestoneService.setMilestone`, and throws `.projectMismatch`, caught by the generic handler as "Could not save task. Please try again." Retrying always fails — the editor is unsaveable with no indication why.++The fix re-applies Decision 6 (moving project clears the milestone) to the rebased draft, in `TaskEditView.rebasedMilestone(milestoneID:projectID:candidates:)`. It follows the existing `availableMilestones` static-helper pattern so it is testable outside the view. The candidate set `[selectedMilestone, task.milestone]` is provably exhaustive: `rebased.milestoneID` originates from either `edited` or `live` by construction.++**Alert re-presentation.** SwiftUI cannot re-present an alert on the same binding inside its own dismissal transaction, hence `presentEditConflict`'s nil-yield-set sequence. This remains the weakest part of the change — see Potential Issues.++### Architecture Impact++`EditMerge` is now a genuine value-semantics merge object rather than a classification result. That is the right shape: consent, rebasing, and conflict description are all derivable from the three snapshots, and adding a fourth editor requires no new policy — only a field enum, a snapshot with `differs` + `replacing`, an applier, and load-once draft state (a value-type form where appropriate).++The asymmetry that made this bug possible survives: `ProjectEditForm` and `MilestoneEditForm` are testable value types, while task draft state remains eight `@State` properties in `TaskEditView`. That is why the task rebase needed a bespoke view-level helper while the other two are pure form methods, and why the cross-field defect landed on the task path specifically. Extracting a `TaskEditForm` would close the gap.++### Potential Issues++- **`presentEditConflict` rests on an unverified ordering.** `await Task.yield()` resumes on the next main-actor turn; the alert's `isPresented` setter writes `nil` when SwiftUI completes dismissal. Nothing orders the two. If the dismissal write lands last, the replacement alert is swallowed and the button appears inert. It fails safe (nothing written) and self-recovers (a second Save takes the direct branch and presents correctly), but it is untested — SwiftUI alert ordering is not unit-testable, and neither `make test` nor `make test-ui` has produced a clean run on this branch. A dismissal-driven queue inside `EditConflictAlert` would remove the assumption.+- **Both alert buttons can re-present**, including the `.cancel`-role one. Under a sufficiently aggressive external writer there is no path out of the alert. Judged acceptable: the write rate required is not realistic for MCP or CloudKit.+- **Form values are now normalised on adopt.** `adoptLiveValues` populates from snapshots, whose initialisers apply `trimmedForFormInput()` (and `normalizedHex` for projects). In-progress text like `"Foo "` is silently trimmed when the user picks "Use Updated Values" on an unrelated conflict. Cosmetic, harmless — form and baseline are normalised identically — but a behaviour change not covered by tests.+- **Consent is intentionally strict.** Users on a fast-syncing device could see the alert re-present more than once. Correct by design; worth watching if it becomes noisy in practice.++---++## Completeness Assessment++**Fully implemented**++- Full-draft rebase from the live snapshot across task, project, and milestone editors (`rebasedEdited`)+- Exact-value consent scoping with re-validation at button-press time (`hasSameConflictSnapshot`)+- Both alert callbacks receiving the shown merge; removal of the `overwritingConflicts: Bool` bypass+- Recompute-before-act in all six action paths+- Cross-field project/milestone consistency on the rebased task draft+- Regression coverage: six merge/rebase/race tests (two per editor), four rebased-milestone tests, two positive consent tests++**Partially implemented**++- **Alert re-presentation.** The mechanism exists and is documented, but rests on a timing assumption with no test coverage and no clean simulator run. Recorded as a known limitation in `report.md`.+- **Testability parity across editors.** Project and milestone rebasing lives in testable form types; task draft state remains in the view, mitigated by a static helper rather than resolved.++**Not implemented (deliberate)**++- Extraction of the duplicated conflict gate repeated across the three editors+- Removal of the now-redundant `edited:` parameter on the three appliers (would require test changes)+- Keypath-based unification of the paired `differs` / `replacing` switches
CLAUDE.md Modified (review fix, 5b7def6) +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex ed5cbea..8345e62 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -102,7 +102,7 @@ All business logic lives in `Services/`, not in views: - **ContainerFactory** — creates ModelContainer with graceful fallback to in-memory on error - **ConnectivityMonitor** — NWPathMonitor wrapper, triggers display ID promotion for both tasks and milestones on connectivity restore - **QuickActionService** — home screen quick action handling-- **EditMerge** (`EditMerge.swift`, plus `TaskEditMerge`/`ProjectEditMerge`/`MilestoneEditMerge`) — three-way merge (load-time baseline vs. form vs. live model) used by the task, project, and milestone editors so a save writes only the fields the user changed and same-field conflicts are surfaced instead of silently resolved. Each editor supplies a field enum, a snapshot, an applier, and a load-once draft form.+- **EditMerge** (`EditMerge.swift`, plus `TaskEditMerge`/`ProjectEditMerge`/`MilestoneEditMerge`) — three-way merge (load-time baseline vs. form vs. live model) used by the task, project, and milestone editors so a save writes only the fields the user changed and same-field conflicts are surfaced instead of silently resolved. The merge retains the original, edited, and live snapshots so conflict consent is scoped to the exact values the alert showed and is re-validated when the button is pressed. Each editor supplies a field enum, a snapshot (including per-field `replacing(_:withValueFrom:)`), an applier, and load-once draft state (a value-type form where appropriate).  ### Navigation 
CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 7870fe7..51c7075 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Fixed +- Editor conflict choices now protect against unseen concurrent changes across task, project, and milestone editors (T-1935). **Use Updated Values** rebases the full draft onto current live values while preserving only genuine non-conflicting user edits, and **Keep My Changes** authorizes only the exact conflict field values shown; either action re-alerts when the conflict snapshot changes.+ - SwiftData test fixtures now retain their backing `ModelContainer` for the full test lifetime (T-2003). `TestModelContainer` is a container-owning fixture instead of a bare-context factory, 207 context-acquisition call sites across 92 test files were migrated, and bespoke context helpers in task-entity, task-creation-result, and report tests now delegate to the shared fixture. An escaped-context lifetime regression plus an executable ownership guard with positive/negative fixtures prevent raw container/context factories from recurring.  - Cross-device milestone name conflicts no longer make name-based operations target an arbitrary record (T-1938). `MilestoneService.findByName` now throws on multiple project-scoped matches; all MCP and App Intent callers report the ambiguity (`AMBIGUOUS_MILESTONE` for intents). Launch/foreground/connectivity maintenance deterministically keeps the oldest milestone name and renames other UUID-distinct records with UUID-derived suffixes, preserving records and task assignments. Cross-context tests simulate CloudKit imports and verify ambiguity reporting, reconciliation, idempotence, and data preservation.

Things to double-check

presentEditConflict on a real device.

Worth one manual pass on both iOS and macOS: open an editor, have MCP change a conflicting field twice, and confirm the replacement alert actually appears rather than the button going inert. This is the one behaviour in the change with no automated coverage at any level, and macOS NSAlert and the iOS alert may not agree on dismissal timing.

Simulator suites have never run clean on this branch.

make test-quick exits 0 with 1567 tests, and make lint is clean. But make test has timed out on every attempt and make test-ui reported six failures attributed to unrelated dashboard/settings assertions plus DTDeviceKit secure-connection errors. Those attributions are plausible but unconfirmed — worth one clean simulator run before merge, on an otherwise idle machine.

TaskEditForm extraction as follow-up.

The task editor keeps its draft in eight @State properties while the project and milestone editors use testable form value types. That asymmetry is why the cross-field defect landed on the task path and needed a bespoke helper rather than being caught by a form-level test. Extracting a TaskEditForm would finish the symmetry the other two editors already have — a reasonable separate ticket, not something to bolt onto this PR.