Exact reviewed range: 887ed8971a88716abd339179b232748acc3b7d4e (base) → d4a419f54c4ea12a118c8da3a47687c965eecc27 (head).
Project before any edit is persisted.make test-quick, focused iOS resolution tests, and make lint; the full iOS run hit its time limit and three existing UI failures remain documented.Ready
LGTM. The exact branch tip closes the silent project-move failure with validation at both the editor and applier boundaries, then makes the disabled state recoverable and accessible. No unresolved review findings are recorded.
b6781d6 T-2018: Fix vanished task editor project selection dd0a610 T-2018: Validate resolved project identity d4a419f T-2018: Surface unavailable project recovery When a project disappears while a task is being edited, the app no longer acts as if its saved ID were still valid. Save stays disabled, explains why, and tells the user to select an available project.
TaskEditProjectSelectionState separates picker-ID state from live-model resolution. The view consumes its presentation values in both platform layouts, while the applier independently rejects a changed unresolved relationship before its transaction mutates any task fields.
The branch preserves the distinction between nil meaning an unchanged relationship and nil for a merge-confirmed project change that lacks a live model. The latter throws before updateTask, milestone work, or status changes, preventing a partial transaction. Recovery strings are derived from the same state predicate, so visual and accessibility behavior cannot disagree with the Save guard.
Transit/Transit/Services/TaskEditMerge.swift
Why it matters. A missing project model can no longer be treated as a valid requested project move, avoiding the prior silent no-op.
What to look at. TaskEditProjectSelectionState and TaskEditApplier validation
Transit/Transit/Views/TaskDetail/TaskEditView+ProjectSelection.swift
Why it matters. A disabled Save control otherwise leaves the user without a reachable recovery path.
What to look at. projectSelectionRecovery and macOSProjectSelectionRecovery
Transit/Transit/Views/TaskDetail/TaskEditView.swift
Why it matters. The error is shown in context below the iOS picker and in a macOS Project error row, rather than only after an attempted save.
What to look at. taskForm and macOSForm recovery placement
Transit/TransitTests/TaskEditProjectResolutionTests.swift
Why it matters. The tests guard both user-facing recovery values and the transaction boundary that prevents unrelated edits from persisting on rejection.
What to look at. TaskEditProjectResolutionTests
The editor blocks an unavailable picker selection for immediate feedback, while the applier rejects any bypass before transaction mutation. This avoids relying on either UI state or rollback alone.
An unchanged optional relationship can legitimately pass nil; only a merge-confirmed project edit must supply the matching live model. This preserves concurrent-edit semantics without accepting a silent project-move failure.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 3a37e80..e95cb60 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - T-1824: MCP `query_milestones` and `QueryMilestonesIntent` now resolve every well-formed `projectId` through `ProjectService` before filtering. A missing project returns the established project-not-found error and an unreadable project store retains its exact storage error, for both full-list and `displayId` queries; malformed UUID validation and `projectId`-over-name precedence are unchanged. Cross-surface regressions cover those contracts. - T-1821: MCP Settings now reconciles a committed port change—including focus loss or Settings closure—through the existing serialized listener lifecycle. A debounced coordinator keeps only the latest enabled change, cancels pending work when MCP is disabled, and flushes pending work on view teardown; invalid ports retain the lifecycle's existing stop/error behavior. The setup command now follows the server's active listener port rather than a persisted draft, and focused state plus live loopback regressions cover coalescing, disabled-server, presentation, and focus-loss replacement behavior.-+- 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.
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
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+}
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 ) }
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 ) }
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)+ }+}
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.