transit PR #237 author @ArjenSchwarz head → base T-2018/bugfix-vanished-project-selection → main commits 3 files 7 touched lines +324 / -10

PR review: #237 — Fix T-2018 vanished task editor project selection

PR #237 by @ArjenSchwarz · merging T-2018/bugfix-vanished-project-selectionmain · view on GitHub

At a glance

  • An unavailable project selection is now explained beside the Project picker instead of only through an unreachable Save error path.
  • The pure selection state supplies matching visible, accessibility-label, and accessibility-hint values; both supported form layouts consume one shared presentation.
  • Five focused iOS regression cases and the macOS suite cover recovery visibility, hiding after resolution, and the pre-existing transactional fail-closed behavior.

Verdict

Ready

The final P2 closes the disabled-action recovery gap without relaxing the fail-closed invariant: an unresolved non-nil project selection keeps Save disabled, now with persistent inline recovery guidance and explicit VoiceOver text on iOS and macOS. The existing merge/applier guards, conflict flow, and ordinary save errors remain intact. Fresh claude-review CI passed, the sentinel review reports no blocking findings, and there are zero unresolved diff threads.

Author's PR description

Shown verbatim — the markdown the author wrote, unmodified.

@ArjenSchwarz · 2026-08-05
## Summary
- Require TaskEditView's selected project UUID to resolve to a live model before Save; a vanished selection disables Save, shows a retryable error, and does not dismiss.
- Make TaskEditApplier reject a changed project with no model before any deferred mutation, while preserving nil for unchanged projects and existing project/milestone safeguards.
- Add deterministic view-state and transactional applier regressions, changelog entry, and bugfix report.

## Validation
- `make build-macos` passed.
- `make test-quick` is blocked before execution by unrelated MCP-port compile failures (`MCPPortChangeState`, `MCPPortChangeCoordinator`, and `MCPServer.activePort` missing).
- `make lint` passes the SwiftData ownership guard and all T-2018 files; it is blocked by the unrelated overlong type name in `MCPPortChangeCoordinatorTests`.

See `specs/bugfixes/vanished-task-edit-project-selection/report.md` for investigation and validation evidence.

Commits

Three-level explanation

What changed: If the project selected in an open task editor disappears, the app keeps Save disabled and shows a clear warning next to the Project picker telling the user to select another available project.

Why it matters: Before this PR, a task could appear to save while silently ignoring the requested project move. The editor now both prevents that save and tells the user how to recover.

Architecture: TaskEditProjectSelectionState remains a pure value type. It derives whether the selected UUID still resolves, the recovery message, and the VoiceOver label/hint. TaskEditView consumes that state in both platform forms, while TaskEditApplier independently rejects a changed project without the matching live model before any deferred mutation.

Trade-off: Save remains fail-closed when the selection is unresolved rather than becoming enabled solely to surface an alert. A persistent form message is the safe reachable recovery affordance.

Invariant boundaries: The UI preflight prevents a stale picker binding from reaching the transaction; the applier independently verifies project.id == edited.projectID for any changed project before updateTask, milestone assignment, or status mutation. This preserves the selective-write three-way merge and saveOrRollback atomicity guarantees. The final state tests pin both the exposed accessibility contract and the disappearance of that state after resolution; transactional tests retain the nil-for-unchanged-project distinction.

Important changes — detailed

Task edit state: retain the fail-closed resolved-project invariant

Transit/Transit/Services/TaskEditMerge.swift

Why it matters. Prevents a requested project relationship change from becoming a silent no-op while preserving the transactional applier guard for every caller.

What to look at. TaskEditProjectSelectionState and TaskEditApplier.apply

Takeaway. A picker UUID is a reference, not proof that its live SwiftData model still exists; derive UI eligibility from a resolved identity and verify it again at the mutation boundary.
Rationale. The original defect was an explicit project move being silently skipped. The P2 requires that the invalid, disabled state provide recovery without enabling the unsafe action.

Task editor forms: show one accessible recovery presentation

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

Why it matters. Turns an otherwise unreachable Save alert into visible, accessible guidance on both iOS and macOS while preserving their native layouts.

What to look at. projectSelectionRecovery and macOSProjectSelectionRecovery

Takeaway. When validation disables an action, expose the reason and next step in the form itself; reuse a single derived state to keep visual and VoiceOver content aligned.
Rationale. A shared Label avoids platform drift and gives the user the actionable path—choose an available project—to re-enable Save.

Task editor regression suite: assert recovery visibility and accessibility

Transit/TransitTests/TaskEditProjectResolutionTests.swift

Why it matters. Pins the P2's user-observable recovery contract in addition to the existing applier atomicity checks.

What to look at. vanishedProjectSelectionBlocksSaveAndProvidesVisibleAccessibleRecovery; resolvedProjectSelectionCanSaveAndHidesRecoveryGuidance

Takeaway. Pure UI-state tests can assert the exact content and lifecycle of an accessibility contract without depending on fragile UI automation.
Rationale. The stale state must remain unsaveable, explain itself, and disappear immediately once a valid selection is restored.

Key decisions

Keep Save disabled for an unresolved selected project

Decision: Do not enable Save merely to reach an error alert. The form displays recovery guidance while the pre-existing fail-closed canSave and save() preflight remain in force.

Use one state-derived recovery contract across platforms

Decision: Keep the message and VoiceOver metadata on TaskEditProjectSelectionState, then render a shared SwiftUI label in the iOS Form and macOS Grid row. The values disappear when the selected UUID resolves again.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex f545349..ce3e3dd 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +- T-2018: Task editing requires the selected project UUID to resolve to a live project model before Save. When a project disappears while the editor is open, Save remains disabled and both iOS and macOS show an inline, VoiceOver-labelled recovery message explaining that the user must choose an available project to re-enable Save. The task-edit applier still fails closed before any mutation when a changed project has no resolved model, while unchanged project fields still accept nil and project-move milestone clearing/validation remain intact. - T-1820: MCP JSON-RPC notification regressions now prove that valid mutating `tools/call` notifications execute their normal side effects while suppressing only their response. Coverage spans direct handler dispatch, successful and failing single HTTP notifications that return 202/no body, ordered mixed batches where a following valid request observes the mutation while notification failures remain response-suppressed, and a batched `initialize` notification that cannot block the following valid request. The MCP route, batch, and notification suites now share one in-process HTTP transport harness. Explicit-null invalid requests, standalone lifecycle rules, all-notification HTTP semantics, and valid request responses remain covered. - T-1898: macOS New Task now retains its in-flight save task and cancels it only when the window/view disappears before persistence completes. The shared T-1858 create-save lifecycle records success before dismissal, so successful creation is not cancelled by its own disappearance; `CancellationError` returns silently while real failures retain the form for retry. iOS dismissal blocking, T-2037 milestone validation, and `TaskService` cancellation safeguards remain intact. A reopened singleton window visibly disables Save while a prior cancellation settles, rather than accepting a Save action that cannot start. Deterministic gated-allocation tests prove a dismissed create inserts no task, alongside successful-dismissal, cancellation-pending, and retry coverage. - T-1936: When the primary SwiftData store cannot open, Transit now derives the effective CloudKit mode from `ContainerFactory`’s fallback outcome rather than retaining the requested sync preference. It records sync inactive before constructing display-ID allocators and promotion wiring, so temporary interactive task/milestone writes remain provisional and cannot advance CloudKit counters. Existing fallback automation-write restrictions and healthy/preference-disabled launch behavior are unchanged.
Transit/Transit/Services/TaskEditMerge.swift Modified +61 / -3
diff --git a/Transit/Transit/Services/TaskEditMerge.swift b/Transit/Transit/Services/TaskEditMerge.swiftindex 2488c90..a474381 100644--- a/Transit/Transit/Services/TaskEditMerge.swift+++ b/Transit/Transit/Services/TaskEditMerge.swift@@ -123,6 +123,46 @@ nonisolated struct TaskEditSnapshot: EditSnapshot { /// (T-1817). typealias TaskEditMerge = EditMerge<TaskEditSnapshot> +// MARK: - Save State++/// The project part of the task editor's save preflight.+///+/// The picker stores an ID while its options are live SwiftData models. A remote+/// delete can leave the ID intact after its model disappears, so a non-nil ID is+/// not enough to make an edit saveable.+nonisolated struct TaskEditProjectSelectionState: Equatable {+    let selectedProjectID: UUID?+    let resolvedProjectID: UUID?++    var isResolved: Bool {+        guard let selectedProjectID else { return false }+        return selectedProjectID == resolvedProjectID+    }++    var errorMessage: String {+        if selectedProjectID == nil {+            "Choose a project before saving."+        } else {+            "The selected project is no longer available. Choose another project and try again."+        }+    }++    /// Persistent in-form recovery guidance for a Save control that is+    /// intentionally disabled while the picker selection cannot be resolved.+    var recoveryMessage: String? {+        isResolved ? nil : errorMessage+    }++    var recoveryAccessibilityLabel: String? {+        guard let recoveryMessage else { return nil }+        return "Project selection error. \(recoveryMessage)"+    }++    var recoveryAccessibilityHint: String? {+        recoveryMessage == nil ? nil : "Select an available project to enable Save."+    }+}+ // MARK: - Applier  /// Writes the fields a `TaskEditMerge` marks as changed, routing every mutation@@ -132,6 +172,17 @@ typealias TaskEditMerge = EditMerge<TaskEditSnapshot> /// commit the whole edit with a single `modelContext.save()` and roll it back as /// a unit on failure. struct TaskEditApplier {+    nonisolated enum Error: Swift.Error, Equatable, LocalizedError {+        case projectNotResolved++        var errorDescription: String? {+            switch self {+            case .projectNotResolved:+                "The selected project is no longer available."+            }+        }+    }+     let taskService: TaskService     let milestoneService: MilestoneService @@ -142,9 +193,16 @@ struct TaskEditApplier {         project: Project?,         milestone: Milestone?     ) throws {-        // Moving project clears the milestone (Decision 6), so it goes first.-        if merge.changed(.project), let project, task.project?.id != project.id {-            try taskService.changeProject(task: task, to: project, save: false)+        // A changed project must be represented by a live model. Failing before+        // any other field is applied preserves the caller's atomic rollback.+        if merge.changed(.project) {+            guard let project, project.id == edited.projectID else {+                throw Error.projectNotResolved+            }+            // Moving project clears the milestone (Decision 6), so it goes first.+            if task.project?.id != project.id {+                try taskService.changeProject(task: task, to: project, save: false)+            }         }          // Unchanged fields are passed as nil, which `updateTask` reads as
Transit/Transit/Views/TaskDetail/TaskEditView+ProjectSelection.swift Added +40 / -0
diff --git a/Transit/Transit/Views/TaskDetail/TaskEditView+ProjectSelection.swift b/Transit/Transit/Views/TaskDetail/TaskEditView+ProjectSelection.swiftnew file mode 100644index 0000000..c66dc2e--- /dev/null+++ b/Transit/Transit/Views/TaskDetail/TaskEditView+ProjectSelection.swift@@ -0,0 +1,40 @@+import Foundation+import SwiftUI++extension TaskEditView {++    /// Resolves the picker ID against the observed project models immediately+    /// before Save, because a remote delete can leave the ID behind.+    var projectSelectionState: TaskEditProjectSelectionState {+        TaskEditProjectSelectionState(+            selectedProjectID: selectedProjectID,+            resolvedProjectID: selectedProject?.id+        )+    }++    /// Visible recovery guidance for an intentionally disabled Save control.+    @ViewBuilder+    var projectSelectionRecovery: some View {+        if let message = projectSelectionState.recoveryMessage,+           let label = projectSelectionState.recoveryAccessibilityLabel,+           let hint = projectSelectionState.recoveryAccessibilityHint {+            Label(message, systemImage: "exclamationmark.triangle.fill")+                .font(.footnote)+                .foregroundStyle(.red)+                .accessibilityElement(children: .combine)+                .accessibilityLabel(label)+                .accessibilityHint(hint)+        }+    }++    #if os(macOS)+    @ViewBuilder+    var macOSProjectSelectionRecovery: some View {+        if projectSelectionState.recoveryMessage != nil {+            FormRow("Project error", labelWidth: Self.labelWidth) {+                projectSelectionRecovery+            }+        }+    }+    #endif+}
Transit/Transit/Views/TaskDetail/TaskEditView.swift Modified +13 / -5
diff --git a/Transit/Transit/Views/TaskDetail/TaskEditView.swift b/Transit/Transit/Views/TaskDetail/TaskEditView.swiftindex 262e09f..1e1ddaa 100644--- a/Transit/Transit/Views/TaskDetail/TaskEditView.swift+++ b/Transit/Transit/Views/TaskDetail/TaskEditView.swift@@ -16,7 +16,7 @@ struct TaskEditView: View {     @State private var selectedType: TaskType = .feature     @State private var selectedPriority: TaskPriority = .medium     @State private var selectedStatus: TaskStatus = .idea-    @State private var selectedProjectID: UUID?+    @State var selectedProjectID: UUID?     @State private var selectedMilestone: Milestone?     @State private var metadata: [String: String] = [:]     @State private var selectedDetent: PresentationDetent = .large@@ -32,7 +32,7 @@ struct TaskEditView: View {     /// changed. Presence drives the conflict alert.     @State private var pendingConflict: TaskEditMerge? -    private var selectedProject: Project? {+    var selectedProject: Project? {         guard let id = selectedProjectID else { return nil }         return projects.first { $0.id == id }     }@@ -46,7 +46,7 @@ struct TaskEditView: View {     }      private var canSave: Bool {-        !name.trimmedForFormInput().isEmpty && selectedProjectID != nil+        !name.trimmedForFormInput().isEmpty && projectSelectionState.isResolved     }      var body: some View {@@ -136,6 +136,8 @@ extension TaskEditView {                 selectedMilestone = nil             } +            projectSelectionRecovery+             Picker("Milestone", selection: $selectedMilestone.milestoneID(from: availableMilestones)) {                 Text("None").tag(nil as UUID?)                 ForEach(availableMilestones) { milestone in@@ -161,7 +163,7 @@ extension TaskEditView {  #if os(macOS) extension TaskEditView {-    fileprivate static let labelWidth: CGFloat = 90+    static let labelWidth: CGFloat = 90      fileprivate var macOSForm: some View {         ScrollView {@@ -217,6 +219,8 @@ extension TaskEditView {                             }                         } +                        macOSProjectSelectionRecovery+                         FormRow("Milestone", labelWidth: Self.labelWidth) {                             Picker("", selection: $selectedMilestone.milestoneID(from: availableMilestones)) {                                 Text("None").tag(nil as UUID?)@@ -333,6 +337,10 @@ extension TaskEditView {     /// 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 projectSelectionState.isResolved, let project = selectedProject else {+            errorMessage = projectSelectionState.errorMessage+            return+        }         guard merge.hasChanges else {             dismissAll()             return@@ -353,7 +361,7 @@ extension TaskEditView {                     merge,                     edited: merge.edited,                     to: task,-                    project: selectedProject,+                    project: project,                     milestone: selectedMilestone                 )             }
Transit/TransitTests/TaskEditConcurrentUpdateTests.swift Modified +2 / -2
diff --git a/Transit/TransitTests/TaskEditConcurrentUpdateTests.swift b/Transit/TransitTests/TaskEditConcurrentUpdateTests.swiftindex 68e5fd8..9f82789 100644--- a/Transit/TransitTests/TaskEditConcurrentUpdateTests.swift+++ b/Transit/TransitTests/TaskEditConcurrentUpdateTests.swift@@ -54,14 +54,14 @@ struct TaskEditTestEnv {         _ merge: TaskEditMerge,         edited: TaskEditSnapshot,         to task: TransitTask,-        project overrideProject: Project? = nil,+        project: Project? = nil,         milestone: Milestone? = nil     ) throws {         try applier.apply(             merge,             edited: edited,             to: task,-            project: overrideProject ?? project,+            project: project,             milestone: milestone         )     }
Transit/TransitTests/TaskEditProjectResolutionTests.swift Added +104 / -0
diff --git a/Transit/TransitTests/TaskEditProjectResolutionTests.swift b/Transit/TransitTests/TaskEditProjectResolutionTests.swiftnew file mode 100644index 0000000..22ae6f9--- /dev/null+++ b/Transit/TransitTests/TaskEditProjectResolutionTests.swift@@ -0,0 +1,104 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// T-2018 regressions for a task-edit project UUID whose model disappeared+/// from the editor's observed project list before Save.+@MainActor @Suite(.serialized)+struct TaskEditProjectResolutionTests {++    @Test func changedProjectWithoutResolvedModelFailsWithoutSavingAnyEdits() async throws {+        let env = try TaskEditTestEnv.make()+        let task = try await env.makeTask()+        let baseline = TaskEditSnapshot(task: task)++        var edited = baseline+        edited.name = "Edit that must not persist"+        edited.projectID = UUID()+        let merge = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))++        #expect(merge.changedFields == [.name, .project])+        #expect(throws: TaskEditApplier.Error.projectNotResolved) {+            try env.context.saveOrRollback {+                try env.apply(merge, edited: edited, to: task, project: nil)+            }+        }++        #expect(task.name == baseline.name)+        #expect(task.project?.id == env.project.id)+        #expect(task.milestone == nil)+    }++    @Test func changedProjectWithMismatchedModelFailsWithoutSavingAnyEdits() async throws {+        let env = try TaskEditTestEnv.make()+        let task = try await env.makeTask()+        let selectedProject = Project(name: "Selected", description: "", gitRepo: nil, colorHex: "#00FF00")+        let wrongProject = Project(name: "Wrong", description: "", gitRepo: nil, colorHex: "#0000FF")+        env.context.insert(selectedProject)+        env.context.insert(wrongProject)+        try env.context.save()++        let baseline = TaskEditSnapshot(task: task)+        var edited = baseline+        edited.name = "Edit that must not persist"+        edited.projectID = selectedProject.id+        let merge = TaskEditMerge(original: baseline, edited: edited, live: TaskEditSnapshot(task: task))++        #expect(merge.changedFields == [.name, .project])+        #expect(throws: TaskEditApplier.Error.projectNotResolved) {+            try env.context.saveOrRollback {+                try env.apply(merge, edited: edited, to: task, project: wrongProject)+            }+        }++        #expect(task.name == baseline.name)+        #expect(task.project?.id == env.project.id)+        #expect(task.milestone == nil)+    }++    @Test func vanishedProjectSelectionBlocksSaveAndProvidesVisibleAccessibleRecovery() {+        let selection = UUID()+        let state = TaskEditProjectSelectionState(+            selectedProjectID: selection,+            resolvedProjectID: nil+        )+        let message = "The selected project is no longer available. Choose another project and try again."++        #expect(state.isResolved == false)+        #expect(state.errorMessage == message)+        #expect(state.recoveryMessage == message)+        #expect(state.recoveryAccessibilityLabel == "Project selection error. \(message)")+        #expect(state.recoveryAccessibilityHint == "Select an available project to enable Save.")+    }++    @Test func resolvedProjectSelectionCanSaveAndHidesRecoveryGuidance() {+        let selection = UUID()+        let state = TaskEditProjectSelectionState(+            selectedProjectID: selection,+            resolvedProjectID: selection+        )++        #expect(state.isResolved)+        #expect(state.recoveryMessage == nil)+        #expect(state.recoveryAccessibilityLabel == nil)+        #expect(state.recoveryAccessibilityHint == nil)+    }++    @Test func unchangedProjectDoesNotRequireAResolvedModelParameter() 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))++        try env.context.saveOrRollback {+            try env.apply(merge, edited: edited, to: task, project: nil)+        }++        #expect(task.priority == .high)+        #expect(task.project?.id == env.project.id)+    }+}
specs/bugfixes/vanished-task-edit-project-selection/report.md Added +103 / -0
diff --git a/specs/bugfixes/vanished-task-edit-project-selection/report.md b/specs/bugfixes/vanished-task-edit-project-selection/report.mdnew file mode 100644index 0000000..f589909--- /dev/null+++ b/specs/bugfixes/vanished-task-edit-project-selection/report.md@@ -0,0 +1,103 @@+# Bugfix Report: Vanished Task Edit Project Selection++**Date:** 2026-08-05+**Status:** Fixed — validated+**Ticket:** T-2018++## Description of the Issue++`TaskEditView` enabled Save when its stored `selectedProjectID` was non-nil, even when the matching `Project` model had disappeared from `@Query` after a local or CloudKit delete. The editor then constructed a merge showing a project change, but `TaskEditApplier` silently skipped that changed field because its `project` argument was nil. The surrounding transaction saved any other edits and dismissed the editor, leaving the task in its original project.++**Reproduction steps:**+1. Open a task assigned to project A.+2. Select project B in the editor.+3. Delete or otherwise remove B before Save, leaving its UUID in the picker binding but no model in the live project query.+4. Make another edit and tap Save.+5. Before this fix, the editor dismissed and did not move the task to B.++**Impact:** An explicit project move could be silently discarded. Other changed fields could still persist, making the successful dismissal misleading.++## Investigation Summary++- **Symptoms examined:** Save availability was based on `selectedProjectID != nil`, while model resolution happened separately through `projects.first`.+- **Code inspected:** `TaskEditView`, `TaskEditApplier`, task-edit merge regressions, project-change/milestone tests, `TaskService.changeProject`, and `MilestoneService.setMilestone`.+- **Hypotheses tested:** Atomic persistence was ruled out: the view already uses one `saveOrRollback` transaction. The failure was an absent validation boundary that allowed the applier to turn a requested project move into a no-op.++## Discovered Root Cause++`TaskEditView` treated a retained UUID as a valid selection even though only the resolved `Project` model can be supplied to `TaskService.changeProject`. `TaskEditApplier` compounded this by using a conditional binding in its project branch, so a missing model silently bypassed a field the merge explicitly marked changed.++**Defect type:** Missing validation / silent no-op.++**Why it occurred:** Picker state and the current SwiftData project collection can change independently when a project is removed while the editor is open. Neither the view nor the applier encoded the invariant that a changed project ID requires a live model.++**Contributing factors:** `TaskEditApplier` correctly accepts `nil` for unchanged optional updates, but the old project branch did not distinguish that valid case from a changed project with no model.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Services/TaskEditMerge.swift` — Adds `TaskEditProjectSelectionState` recovery presentation values and makes `TaskEditApplier` throw `projectNotResolved` before any mutation when a merge changed the project but no matching live model was supplied. An unchanged project still accepts a nil parameter.+- `Transit/Transit/Views/TaskDetail/TaskEditView.swift` — Keeps Save disabled unless the selected UUID resolves to a current model, repeats that preflight before conflict/no-change handling, and places the shared recovery view directly below the iOS picker and in a macOS `Project error` form row. The shared macOS form width is module-visible solely for the split extension.+- `Transit/Transit/Views/TaskDetail/TaskEditView+ProjectSelection.swift` — Keeps resolution and the shared in-form `Label` presentation separate so the main view remains under the enforced file-length limit. The label is visible in error styling and has explicit VoiceOver label/hint text explaining how to re-enable Save.+- `Transit/TransitTests/TaskEditConcurrentUpdateTests.swift` — Lets the shared applier fixture pass a genuine nil project for unchanged-project tests.+- `Transit/TransitTests/TaskEditProjectResolutionTests.swift` — Adds deterministic transactional applier, selection-state, visible/accessibility recovery, and unchanged-nil regressions.++**Approach rationale:** The editor now exposes a persistent recovery state rather than relying on an unreachable alert behind its disabled Save control. A user can immediately understand why Save is unavailable and choose a remaining project; the state and its accessible metadata disappear as soon as that selection resolves. The applier independently rejects misuse from any caller. Its guard runs before `updateTask`, milestone assignment, or status changes, preserving all-or-nothing rollback semantics; merge conflict and ordinary save-error flows remain unchanged.++**Alternatives considered:**+- Enable Save just to show the existing alert — rejected because it briefly permits a known-invalid action and weakens the existing `canSave` invariant.+- Silently keep the original project — rejected because it is the existing data-loss behavior.+- Require a project model for every applier call — rejected because unchanged project fields intentionally pass nil so concurrent project changes are not overwritten.+- Clear the stale project UUID automatically — rejected because it hides the removed selection rather than explaining why Save cannot proceed.++## Regression Test++**Test file:** `Transit/TransitTests/TaskEditProjectResolutionTests.swift`++**Tests:**+- `changedProjectWithoutResolvedModelFailsWithoutSavingAnyEdits` — wraps the applier in the editor's `saveOrRollback` transaction, expects the typed error, and asserts name, project, and milestone remain unchanged.+- `changedProjectWithMismatchedModelFailsWithoutSavingAnyEdits` — proves a non-nil but wrong project model cannot bypass the edited UUID check or save an unrelated edit.+- `vanishedProjectSelectionBlocksSaveAndProvidesVisibleAccessibleRecovery` — asserts a stale UUID/no-model state remains unsaveable while exposing the exact visible recovery message and VoiceOver label/hint consumed by both platform forms.+- `resolvedProjectSelectionCanSaveAndHidesRecoveryGuidance` — asserts matching ID/model state remains saveable and removes all recovery presentation values.+- `unchangedProjectDoesNotRequireAResolvedModelParameter` — asserts unrelated edits succeed with a nil project parameter.++Existing `TaskEditOrdinaryEditTests.changingProjectClearsMilestone` continues to cover Decision 6 project-move milestone clearing; `MilestoneService` project-match validation is unchanged.++**Run command:** `make test-quick`, plus the focused iOS class shown in `CLAUDE.md`.++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/TaskEditMerge.swift` | Resolved-selection state, visible/accessibility recovery values, and fail-closed applier error |+| `Transit/Transit/Views/TaskDetail/TaskEditView.swift` | Save availability/no-dismiss preflight plus iOS/macOS recovery placement |+| `Transit/Transit/Views/TaskDetail/TaskEditView+ProjectSelection.swift` | Extracted resolution and shared accessible recovery presentation |+| `Transit/TransitTests/TaskEditConcurrentUpdateTests.swift` | Nil-preserving applier fixture |+| `Transit/TransitTests/TaskEditProjectResolutionTests.swift` | Deterministic transactional and UI-state regressions |+| `CHANGELOG.md` | T-2018 release note |++## Verification++**Automated:**+- [x] `make test-quick` — passed after the final source changes.+- [x] Focused iOS `TransitTests/TaskEditProjectResolutionTests` — passed all five regression cases after the final source changes.+- [x] `make lint` — passed, including the SwiftData ownership guard.+- [x] `make build-macos` — passed before the final split extraction; the final macOS source then compiled and passed through `make test-quick`.+- [ ] `make test` — exceeded the 120-second runner limit during the initial iOS dependency build before tests began. The build compiled both changed production files without errors; the focused iOS suite passed afterward.+- [ ] `make test-ui` — executed the UI suite but retained three pre-existing failures: `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`. The relevant `TransitUITests.testMilestoneClearedOnProjectChange` passed.++**Manual verification:** The stale-state presentation is deterministically modelled by `TaskEditProjectSelectionState` and exercised through its exact visible/VoiceOver strings. Manual UI verification was not performed.++## Prevention++- Treat picker IDs as references, not proof that a live SwiftData model exists.+- When invalid state intentionally disables an action, expose persistent visual and accessibility recovery guidance outside that action.+- Validate a changed relationship at both the view and service/applier boundary.+- Preserve `nil` as “unchanged” only when the merge confirms that field was not edited.+- Keep fail-closed guards before any deferred transaction mutation so rollback is a safety net rather than the only protection.++## Related++- T-1798 — task editor three-way merge and selective writes.+- T-1935 — conflict rebasing and project/milestone pairing safeguards.+- Decision 6 — moving a task project clears its milestone.

Things to double-check

Known UI-suite health

The local UI suite still reports testClearAll, testEditViewPreservesTaskMilestone, and testDataMaintenanceGoldenPath; the focused project-change UI test and new focused iOS unit suite pass. These known failures are recorded in the bugfix report and are not attributed to this PR.