PR #220 at exact reviewed head 587ee88b0ccb6563920a00c7550a73b438985355, compared with e4c1bf7f72c45a1cf6ddb0751ab12c0fd1f4b766.
Ready to push/merge
PASS. No actionable defects remain at the exact head. The final persistence guard closes the stale terminal-milestone race before insertion, and the UI, App Intent, and MCP surfaces expose the same protected creation path.
Baseline caveat: macOS is established at 1,742 passed / 0 failed; iOS is established at 1,230 passed / 3 failed. The three iOS failures are baseline failures, not attributed to this head.
587ee88b0ccb6563920a00c7550a73b438985355 T-2037: Honor legacy milestone status fallback 0d23615572ae0d0a200c1c1dc996d7e41e7656e1 T-2037: Cover terminal milestone creation surfaces af69212a928a5c2bfb74ea504580a28e679c5e37 T-2037: Clear stale Add Task milestone selection f7765d6d3caf00d6047d56e3b8dbc03ba66780ed T-2037: Revalidate stale Add Task milestones 316638ecdb3ff83a21982a5de9c94d109ddbf0c5 T-2037: Add stale milestone regression coverage A task can no longer be saved into a milestone that another window closed while the Add Task form was open. The form clears an option once it is no longer open, and saving checks once more before creating anything.
The safeguard belongs in TaskService.createTask, not only in SwiftUI. It reads the milestone from the current context (pending local state) and a new context (committed peer state) after the final awaited allocation and before the one-save aggregate insertion.
This closes a SwiftData TOCTOU boundary without adding another suspension point. The validator preserves the model's legacy unknown-status-to-open fallback, rejects absent/terminal or project-mismatched records before insertOrDelete, and leaves both live and committed stores task-free on rejection.
Transit/Transit/Services/TaskCreationMilestoneValidator.swift
Why it matters. Prevents a peer context from closing a milestone during display-ID allocation and still receiving a new task.
What to look at. TaskCreationMilestoneValidator.validate
Transit/Transit/Services/TaskService.swift
Why it matters. The shared service boundary keeps UI, App Intent, and MCP task creation behavior aligned and preserves atomic one-save cleanup.
What to look at. TaskService.createTask
Transit/Transit/Views/AddTask/AddTaskSheet.swift
Why it matters. Keeps the draft state consistent with the picker after a milestone changes in another context.
What to look at. openMilestones and AddTaskMilestoneSelectionLogic
Transit/TransitTests/AddTaskStaleMilestoneTests.swift
Why it matters. Deterministically exercises the race for Done and Abandoned and verifies both pending and committed stores remain empty.
What to look at. persistRejectsMilestoneClosedAfterSelectionBeforeSaveWithoutInsertingTask
The change intentionally protects new-task creation only. Existing-task milestone assignment remains project-match-only, preserving the established editor behavior for pre-existing terminal assignments.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 015102e..5c6a897 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - T-1613: MCP `query_tasks` now returns the exact tool error `Failed to fetch comments: <error>` when comment serialization cannot read storage, rather than reporting a successful task with `comments: []`. The detailed display-ID and task-list paths share this throwing serialization boundary; genuine empty comment collections retain their successful `comments: []` response. `update_task_status` continues serializing the `Comment` returned by its atomic mutation directly (T-1823), so it performs no post-commit comment fetch that could prompt a retry or duplicate a persisted comment. Deterministic MCP regressions cover both query errors, legitimate empty comments, and zero status-response fetches. - T-1620: `GenerateReportIntent` now returns the established `INTERNAL_ERROR` JSON envelope with a source-specific stable hint when terminal task or milestone fetches fail, instead of false empty-report Markdown. Successful empty reports retain their existing Markdown and date-range formatting; deterministic regressions cover both failures and the valid-empty response.+- T-2037: Add Task observes the selected project's milestones and clears a selection that leaves the open picker options. It also revalidates an optional milestone at the final pre-insertion boundary using both live and fresh committed SwiftData state. A milestone closed in another window/context while display-ID allocation awaits now rejects with a user-facing error before any task insertion; nil milestones, same-project validation, one-save task/milestone creation, and dashboard terminal-filter behavior remain unchanged. Deterministic two-context regressions cover both Done and Abandoned transitions and verify no pending or committed task survives. - T-1608: MCP `query_tasks` now surfaces a tool error rather than a false successful `[]` when either unscoped milestone-name resolution (`Failed to fetch milestones: <error>`) or `milestoneDisplayId` resolution (`Failed to look up milestone: <error>`) cannot read storage. It validates every filter shape before milestone resolution, so malformed filters return their validation error before any milestone lookup outcome. Valid no-match arrays, cross-project same-name aggregation, project-scoped milestone lookup and T-1938 ambiguity handling, response shape, and later task-fetch errors are unchanged; deterministic MCP coverage asserts both exact errors and the legitimate no-match response. - T-1607: Visual App Entity project/task queries and task-creation result resolution now rethrow storage fetch failures through their existing `async throws` contract instead of returning successful empty arrays. Deterministic regression coverage spans `entities(for:)` and `suggestedEntities()` for all three query types, while valid empty/invalid identifier results, ordering, suggestion limits, and partial relationship skipping remain unchanged. - Display-ID collision guards now build candidate-blocking sets from committed task/milestone IDs fetched through a fresh transient `ModelContext`, unioned with live/pending registered main-context values and allocator-issued IDs (T-1939). The shared guard applies to task and milestone creation, provisional promotion, and duplicate repair, so a stale registered bystander can no longer hide a peer-synced committed ID. Regression coverage commits the peer value through an independent context, proves the receiving bystander remains clean and unrefreshed, and separately verifies unsaved IDs remain blocked. Either store view failing still fails closed; allocation serialization, cancellation, maintenance stale-loser probes, and selective save recovery are unchanged.
diff --git a/Transit/Transit/Intents/CreateTaskIntent.swift b/Transit/Transit/Intents/CreateTaskIntent.swiftindex b20b259..3123c21 100644--- a/Transit/Transit/Intents/CreateTaskIntent.swift+++ b/Transit/Transit/Intents/CreateTaskIntent.swift@@ -167,6 +167,8 @@ struct CreateTaskIntent: AppIntent { .projectNotFound(hint: "The selected project could not be found") case .milestoneProjectMismatch: .milestoneProjectMismatch(hint: "Milestone and task must belong to the same project")+ case .milestoneNotOpen:+ .invalidInput(hint: "The selected milestone is no longer open") default: .internalError(hint: "Task creation failed") }
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex 65ff3ef..88b2ef3 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -441,6 +441,8 @@ final class MCPToolHandler { priority: priority, milestone: resolvedMilestone )+ } catch TaskService.Error.milestoneNotOpen {+ return errorResult("The selected milestone is no longer open") } catch { return errorResult("Task creation failed: \(error)") }
diff --git a/Transit/Transit/Services/TaskCreationMilestoneValidator.swift b/Transit/Transit/Services/TaskCreationMilestoneValidator.swiftnew file mode 100644index 0000000..fc2fde6--- /dev/null+++ b/Transit/Transit/Services/TaskCreationMilestoneValidator.swift@@ -0,0 +1,37 @@+import Foundation+import SwiftData++/// Validates a task-creation milestone from both state sources that can be+/// current: the live context preserves pending local updates, while a fresh+/// context observes a peer's committed update even when the live model is+/// clean but stale. Every observed record must still be open and scoped to+/// `projectID`; a fetch error fails closed before task insertion. [T-2037]+enum TaskCreationMilestoneValidator {+ static func validate(+ _ milestone: Milestone?,+ projectID: UUID,+ in modelContext: ModelContext+ ) throws {+ guard let milestone else { return }++ let milestoneID = milestone.id+ let descriptor = FetchDescriptor<Milestone>(+ predicate: #Predicate { $0.id == milestoneID }+ )+ let liveMilestone = try modelContext.fetch(descriptor).first+ let committedMilestone = try ModelContext(modelContext.container).fetch(descriptor).first+ let currentMilestones = [liveMilestone, committedMilestone].compactMap { $0 }++ guard !currentMilestones.isEmpty else {+ throw TaskService.Error.milestoneNotOpen+ }+ for currentMilestone in currentMilestones {+ guard currentMilestone.project?.id == projectID else {+ throw TaskService.Error.milestoneProjectMismatch+ }+ guard currentMilestone.status == .open else {+ throw TaskService.Error.milestoneNotOpen+ }+ }+ }+}
diff --git a/Transit/Transit/Services/TaskService+Error.swift b/Transit/Transit/Services/TaskService+Error.swiftnew file mode 100644index 0000000..6a56403--- /dev/null+++ b/Transit/Transit/Services/TaskService+Error.swift@@ -0,0 +1,36 @@+import Foundation++extension TaskService {+ enum Error: Swift.Error, LocalizedError, Equatable {+ case invalidName+ case taskNotFound+ case projectNotFound+ case duplicateDisplayID+ case restoreRequiresAbandonedTask+ case milestoneProjectMismatch+ case milestoneNotOpen+ /// Identifier key present but malformed; field name surfaces a field-specific INVALID_INPUT [T-808]+ case invalidIdentifier(field: String)++ var errorDescription: String? {+ switch self {+ case .invalidName:+ "Task name cannot be empty."+ case .taskNotFound:+ "The specified task could not be found."+ case .projectNotFound:+ "The selected project could not be found."+ case .duplicateDisplayID:+ "A duplicate task identifier was detected."+ case .restoreRequiresAbandonedTask:+ "Only abandoned tasks can be restored."+ case .milestoneProjectMismatch:+ "Milestone and task must belong to the same project."+ case .milestoneNotOpen:+ "The selected milestone is no longer open."+ case .invalidIdentifier(let field):+ "The supplied \(field) is not a valid task identifier."+ }+ }+ }+}
diff --git a/Transit/Transit/Services/TaskService.swift b/Transit/Transit/Services/TaskService.swiftindex defc65d..6302e76 100644--- a/Transit/Transit/Services/TaskService.swift+++ b/Transit/Transit/Services/TaskService.swift@@ -4,39 +4,8 @@ import SwiftData /// Coordinates task creation, status changes, and lookups. Uses StatusEngine /// for all status transitions and DisplayIDAllocator for display ID assignment. @MainActor @Observable-// swiftlint:disable:next type_body_length final class TaskService { - enum Error: Swift.Error, LocalizedError, Equatable {- case invalidName- case taskNotFound- case projectNotFound- case duplicateDisplayID- case restoreRequiresAbandonedTask- case milestoneProjectMismatch- /// Identifier key present but malformed; field name surfaces a field-specific INVALID_INPUT [T-808]- case invalidIdentifier(field: String)-- var errorDescription: String? {- switch self {- case .invalidName:- "Task name cannot be empty."- case .taskNotFound:- "The specified task could not be found."- case .projectNotFound:- "The selected project could not be found."- case .duplicateDisplayID:- "A duplicate task identifier was detected."- case .restoreRequiresAbandonedTask:- "Only abandoned tasks can be restored."- case .milestoneProjectMismatch:- "Milestone and task must belong to the same project."- case .invalidIdentifier(let field):- "The supplied \(field) is not a valid task identifier."- }- }- }- private let modelContext: ModelContext private let displayIDAllocator: DisplayIDAllocator private let createSave: (ModelContext) throws -> Void@@ -157,6 +126,11 @@ final class TaskService { // and inserting the model so a successfully allocated ID cannot turn a // cancelled operation into a persisted task (T-1765). try Task.checkCancellation()+ try TaskCreationMilestoneValidator.validate(+ milestone,+ projectID: project.id,+ in: modelContext+ ) let task = TransitTask( name: trimmedName,
diff --git a/Transit/Transit/Views/AddTask/AddTaskSheet.swift b/Transit/Transit/Views/AddTask/AddTaskSheet.swiftindex 3fec062..adecae5 100644--- a/Transit/Transit/Views/AddTask/AddTaskSheet.swift+++ b/Transit/Transit/Views/AddTask/AddTaskSheet.swift@@ -8,11 +8,11 @@ struct AddTaskSheet: View { // widening is incidental, so do not re-tighten these to `private`. @Environment(TaskService.self) var taskService @Environment(ProjectService.self) private var projectService- @Environment(MilestoneService.self) var milestoneService @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) var dismiss @Environment(\.resolvedTheme) private var resolvedTheme @Query(sort: \Project.name) private var projects: [Project]+ @Query(sort: \Milestone.name) private var milestones: [Milestone] @State var name = "" @State var taskDescription = ""@@ -31,7 +31,9 @@ struct AddTaskSheet: View { private var openMilestones: [Milestone] { guard let project = selectedProject else { return [] }- return milestoneService.milestonesForProject(project, status: .open)+ return milestones.filter {+ $0.project?.id == project.id && $0.status == .open+ } } private var canSave: Bool {@@ -103,6 +105,14 @@ struct AddTaskSheet: View { } #endif }+ .onChange(of: openMilestones.map(\.id), initial: true) { _, availableMilestoneIDs in+ if AddTaskMilestoneSelectionLogic.shouldClearSelection(+ selectedMilestoneID: selectedMilestone?.id,+ availableMilestoneIDs: availableMilestoneIDs+ ) {+ selectedMilestone = nil+ }+ } } // MARK: - iOS Layout@@ -310,3 +320,16 @@ enum AddTaskFormResetLogic { return projects.first?.id } }++/// Decides whether an Add Task milestone selection remains representable by the+/// picker. Unlike task editing, creation has no existing terminal assignment to+/// preserve, so an option that leaves the open set must be cleared from state.+enum AddTaskMilestoneSelectionLogic {+ static func shouldClearSelection(+ selectedMilestoneID: UUID?,+ availableMilestoneIDs: [UUID]+ ) -> Bool {+ guard let selectedMilestoneID else { return false }+ return !availableMilestoneIDs.contains(selectedMilestoneID)+ }+}
diff --git a/Transit/TransitTests/AddTaskSheetResetTests.swift b/Transit/TransitTests/AddTaskSheetResetTests.swiftindex ca53b51..7312ea0 100644--- a/Transit/TransitTests/AddTaskSheetResetTests.swift+++ b/Transit/TransitTests/AddTaskSheetResetTests.swift@@ -27,6 +27,35 @@ struct AddTaskSheetResetTests { #expect(defaults.milestone == nil) } + // MARK: - Milestone selection reconciliation++ @Test("A selected milestone is cleared when it is no longer an open option")+ func staleMilestoneSelectionIsCleared() {+ #expect(+ AddTaskMilestoneSelectionLogic.shouldClearSelection(+ selectedMilestoneID: UUID(),+ availableMilestoneIDs: []+ )+ )+ }++ @Test("An available or absent milestone selection is retained")+ func validOrAbsentMilestoneSelectionIsRetained() {+ let milestoneID = UUID()+ #expect(+ !AddTaskMilestoneSelectionLogic.shouldClearSelection(+ selectedMilestoneID: milestoneID,+ availableMilestoneIDs: [milestoneID]+ )+ )+ #expect(+ !AddTaskMilestoneSelectionLogic.shouldClearSelection(+ selectedMilestoneID: nil,+ availableMilestoneIDs: []+ )+ )+ }+ // MARK: - Default project selection @Test("Default project picks the first project when none selected")
diff --git a/Transit/TransitTests/AddTaskStaleMilestoneTests.swift b/Transit/TransitTests/AddTaskStaleMilestoneTests.swiftnew file mode 100644index 0000000..84220c4--- /dev/null+++ b/Transit/TransitTests/AddTaskStaleMilestoneTests.swift@@ -0,0 +1,145 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// Regression coverage for T-2037: Add Task must revalidate an optional+/// milestone immediately before insertion, because a peer context can close+/// the selected milestone while task creation awaits display-ID allocation.+@MainActor @Suite(.serialized)+struct AddTaskStaleMilestoneTests {++ private func makeProject(in context: ModelContext) -> Project {+ let project = Project(+ name: "Test Project",+ description: "A test project",+ gitRepo: nil,+ colorHex: "#FF0000"+ )+ context.insert(project)+ return project+ }++ private func makeMilestone(in context: ModelContext, project: Project) -> Milestone {+ let milestone = Milestone(+ name: "v1.0",+ description: nil,+ project: project,+ displayID: .permanent(1)+ )+ context.insert(milestone)+ return milestone+ }++ @Test(arguments: [MilestoneStatus.done, .abandoned])+ func persistRejectsMilestoneClosedAfterSelectionBeforeSaveWithoutInsertingTask(+ terminalStatus: MilestoneStatus+ ) async throws {+ let testContainer = try TestModelContainer()+ let context = testContainer.context+ let allocationStore = AllocationGatedCounterStore()+ let taskService = TaskService(+ modelContext: context,+ displayIDAllocator: DisplayIDAllocator(store: allocationStore)+ )+ let project = makeProject(in: context)+ let selectedMilestone = makeMilestone(in: context, project: project)+ try context.save()++ let draft = AddTaskSheet.TaskDraft(+ name: "Stale Milestone Task",+ description: nil,+ type: .feature,+ priority: .medium,+ projectID: project.id,+ milestone: selectedMilestone+ )+ let persistence = Task { @MainActor in+ try await AddTaskSheet.persist(draft: draft, taskService: taskService)+ }++ #expect(await allocationStore.waitUntilAllocationStarts())++ let selectedMilestoneID = selectedMilestone.id+ let peerContext = ModelContext(testContainer.container)+ let peerMilestone = try #require(try peerContext.fetch(FetchDescriptor<Milestone>(+ predicate: #Predicate { $0.id == selectedMilestoneID }+ )).first)+ peerMilestone.status = terminalStatus+ try peerContext.save()++ #expect(selectedMilestone.status == .open, "The selected model must remain stale in its context")+ await allocationStore.releaseAllocation()++ do {+ try await persistence.value+ Issue.record("Expected persistence to reject the now-terminal milestone")+ } catch let error as TaskService.Error {+ #expect(error == .milestoneNotOpen)+ } catch {+ Issue.record("Expected milestoneNotOpen, got \(error)")+ }++ #expect(try context.fetch(FetchDescriptor<TransitTask>()).isEmpty)+ let committedContext = ModelContext(testContainer.container)+ #expect(try committedContext.fetch(FetchDescriptor<TransitTask>()).isEmpty)+ }++ @Test(arguments: [MilestoneStatus.done, .abandoned])+ func directCreationRejectsAlreadyTerminalMilestoneWithoutInsertingTask(+ terminalStatus: MilestoneStatus+ ) async throws {+ let testContainer = try TestModelContainer()+ let context = testContainer.context+ let taskService = TaskService(+ modelContext: context,+ displayIDAllocator: DisplayIDAllocator(store: InMemoryCounterStore())+ )+ let project = makeProject(in: context)+ let milestone = makeMilestone(in: context, project: project)+ milestone.status = terminalStatus+ try context.save()++ do {+ _ = try await taskService.createTask(+ name: "Terminal Milestone Task",+ description: nil,+ type: .feature,+ project: project,+ milestone: milestone+ )+ Issue.record("Expected terminal milestone creation to fail")+ } catch let error as TaskService.Error {+ #expect(error == .milestoneNotOpen)+ } catch {+ Issue.record("Expected milestoneNotOpen, got \(error)")+ }++ #expect(try context.fetch(FetchDescriptor<TransitTask>()).isEmpty)+ let committedContext = ModelContext(testContainer.container)+ #expect(try committedContext.fetch(FetchDescriptor<TransitTask>()).isEmpty)+ }++ @Test func directCreationAcceptsMilestoneWithLegacyUnknownStatus() async throws {+ let testContainer = try TestModelContainer()+ let context = testContainer.context+ let taskService = TaskService(+ modelContext: context,+ displayIDAllocator: DisplayIDAllocator(store: InMemoryCounterStore())+ )+ let project = makeProject(in: context)+ let milestone = makeMilestone(in: context, project: project)+ milestone.statusRawValue = "legacy-status"+ try context.save()++ let task = try await taskService.createTask(+ name: "Legacy Milestone Task",+ description: nil,+ type: .feature,+ project: project,+ milestone: milestone+ )++ #expect(task.milestone?.id == milestone.id)+ }+}
diff --git a/Transit/TransitTests/CreateTaskIntentMilestoneTests.swift b/Transit/TransitTests/CreateTaskIntentMilestoneTests.swiftindex 847f048..0c23030 100644--- a/Transit/TransitTests/CreateTaskIntentMilestoneTests.swift+++ b/Transit/TransitTests/CreateTaskIntentMilestoneTests.swift@@ -104,6 +104,31 @@ struct CreateTaskIntentMilestoneTests { #expect(milestoneInfo?["name"] as? String == "v1.0") } + @Test(arguments: [MilestoneStatus.done, .abandoned])+ func createTaskWithTerminalMilestoneReturnsInvalidInputWithoutCreatingTask(+ terminalStatus: MilestoneStatus+ ) async throws {+ let svc = try makeServices()+ let project = makeProject(in: svc.context)+ let milestone = makeMilestone(in: svc.context, name: "v1.0", project: project, displayId: 1)+ try svc.milestone.updateStatus(milestone, to: terminalStatus)++ let input = """+ {"name":"Task","type":"feature","project":"\(project.name)","milestoneDisplayId":1}+ """+ let result = await CreateTaskIntent.execute(+ input: input,+ taskService: svc.task,+ projectService: svc.project,+ milestoneService: svc.milestone+ )++ let parsed = try parseJSON(result)+ #expect(parsed["error"] as? String == "INVALID_INPUT")+ #expect(parsed["hint"] as? String == "The selected milestone is no longer open")+ #expect(try svc.context.fetch(FetchDescriptor<TransitTask>()).isEmpty)+ }+ @Test func createTaskWithMilestoneSaveFailureIsAtomic() async throws { var expectedMilestoneID: UUID? let svc = try makeServices { context in
diff --git a/Transit/TransitTests/MCPCreateTaskTerminalMilestoneTests.swift b/Transit/TransitTests/MCPCreateTaskTerminalMilestoneTests.swiftnew file mode 100644index 0000000..88b6365--- /dev/null+++ b/Transit/TransitTests/MCPCreateTaskTerminalMilestoneTests.swift@@ -0,0 +1,38 @@+#if os(macOS)+import Foundation+import SwiftData+import Testing+@testable import Transit++/// T-2037 regression: MCP task creation must reject terminal milestones before+/// inserting a task, while existing-task milestone assignment remains unchanged.+@MainActor @Suite(.serialized)+struct MCPCreateTaskTerminalMilestoneTests {++ @Test(arguments: [MilestoneStatus.done, .abandoned])+ func createTaskWithTerminalMilestoneReturnsErrorWithoutCreatingTask(+ terminalStatus: MilestoneStatus+ ) async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ let milestone = try await env.milestoneService.createMilestone(+ name: "v1.0", description: nil, project: project+ )+ try env.milestoneService.updateStatus(milestone, to: terminalStatus)++ let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+ tool: "create_task",+ arguments: [+ "name": "Terminal Milestone Task",+ "type": "feature",+ "projectId": project.id.uuidString,+ "milestoneDisplayId": milestone.permanentDisplayId!+ ]+ ))++ #expect(try MCPTestHelpers.isError(response))+ #expect(try MCPTestHelpers.errorText(response).contains("no longer open"))+ #expect(try env.context.fetch(FetchDescriptor<TransitTask>()).isEmpty)+ }+}+#endif
diff --git a/specs/bugfixes/add-task-stale-terminal-milestone/report.md b/specs/bugfixes/add-task-stale-terminal-milestone/report.mdnew file mode 100644index 0000000..3b8a015--- /dev/null+++ b/specs/bugfixes/add-task-stale-terminal-milestone/report.md@@ -0,0 +1,91 @@+# Bugfix Report: Add Task Stale Terminal Milestone++**Date:** 2026-08-04+**Status:** Fixed++## Description of the Issue++Add Task retained an in-memory milestone selection after that milestone became Done or Abandoned in another window, MCP call, or synced context. The picker correctly stopped offering the milestone because it displays only open records, but saving still persisted a new task assigned to the stale terminal model.++**Reproduction steps:**+1. Open Add Task and select an open milestone.+2. In another context, change the milestone to Done or Abandoned.+3. Return to the still-open Add Task form and save.+4. Observe that a new task is persisted against the terminal milestone.++**Impact:** New tasks can be added to closed milestones, making the Add Task picker’s open-only contract unreliable.++## Investigation Summary++- **Symptoms examined:** The picker filters to open milestones but its UUID binding keeps the selected model when it disappears from the options.+- **Code inspected:** `AddTaskSheet`, `AddTaskSheet+Save`, `TaskService.createTask`, `MilestoneService`, and the dashboard milestone filter.+- **Hypotheses tested:** The defect is not a dashboard filter issue (T-1825). The task creation path validates only project identity before awaiting display-ID allocation and never checks the milestone’s current status.++## Discovered Root Cause++**Defect type:** Time-of-check/time-of-use validation gap with stale cross-context model state.++**Why it occurred:**+1. The form captured a `Milestone` object while its status was open.+2. Another context committed a terminal status while the form’s context retained the clean, stale object.+3. `TaskService.createTask` checked only the stale object’s project identity before awaiting display-ID allocation.+4. The task was inserted and saved without re-reading current milestone state at the pre-persistence boundary.++**Contributing factors:** UI option filtering and `onChange` cannot guard against an external status change after selection or while an awaited operation is in progress.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Services/TaskCreationMilestoneValidator.swift` — reads the selected milestone from the live context and a fresh transient context, requiring every available view to remain open and project-matched.+- `Transit/Transit/Services/TaskService.swift` — calls that validation after the display-ID allocation await and cancellation re-check, immediately before constructing and inserting the task.+- `Transit/Transit/Views/AddTask/AddTaskSheet.swift` — observes the selected project's milestone records and clears a selection as soon as it leaves the open option set, so the picker and draft do not retain a terminal selection.+- `Transit/Transit/Services/TaskService+Error.swift` — adds the localized `milestoneNotOpen` error surfaced by Add Task.+- `Transit/Transit/Intents/CreateTaskIntent.swift` — maps the new service error to `INVALID_INPUT` rather than an internal failure.+- `Transit/Transit/MCP/MCPToolHandler.swift` — returns the localized terminal-milestone message rather than an enum case name from `create_task`.++**Approach rationale:** The guard sits after the last suspension point and before `TransitTask` construction, closing the status-change race without relying on picker refreshes or `onChange`. It combines live state (including local pending updates) with a fresh committed-store read (including peer/window updates), and rejects before `insertOrDelete`, preserving the existing atomic one-save task/milestone path.++**Alternatives considered:**+- Clearing `selectedMilestone` only when the picker options change — rejected because it misses a closure that occurs after selection or during awaited task creation, and does not protect non-UI callers.+- Changing the dashboard terminal filter — rejected because T-1825 is separate behavior and remains unchanged.++## Regression Test++**Test file:** `Transit/TransitTests/AddTaskStaleMilestoneTests.swift`+**Test name:** `persistRejectsMilestoneClosedAfterSelectionBeforeSaveWithoutInsertingTask`++**What it verifies:** A gated display-ID allocation permits a peer context to change a formerly selected milestone to Done or Abandoned deterministically. The Add Task persistence path must reject the stale selection with `milestoneNotOpen` and leave no pending or committed task.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|---|---|+| `Transit/Transit/Services/TaskCreationMilestoneValidator.swift` | Validates project scope and open status against live and committed milestone state. |+| `Transit/Transit/Services/TaskService.swift` | Applies validation after allocation and before task construction/insertion. |+| `Transit/Transit/Services/TaskService+Error.swift` | Adds localized terminal/unavailable milestone rejection. |+| `Transit/Transit/Intents/CreateTaskIntent.swift` | Maps rejection to `INVALID_INPUT`. |+| `Transit/TransitTests/AddTaskStaleMilestoneTests.swift` | Covers deterministic peer-context Done/Abandoned closure with no partial task. |++## Verification++**Automated:**+- [x] Focused regression passes for Done and Abandoned (`AddTaskStaleMilestoneTests`)+- [x] macOS unit suite passes (`make test-quick`)+- [x] Strict lint passes (`make lint`)+- [ ] iOS suite (`make test`) did not complete: two attempts timed out after building and beginning tests; Xcode logged repeated LLDB debugger-version-store failures.+- [ ] UI suite (`make test-ui`) not run after the full iOS suite could not complete in this environment.++**Manual verification:**+- The deterministic two-context test models the Add Task selection, external closure, and save boundary directly; no manual UI run was performed.++## Prevention++- Validate mutable relationship invariants at the final persistence boundary, not only while collecting UI state.+- Combine a transient committed-store read with live context state when a selected SwiftData model may be stale after a cross-window or CloudKit update.++## Related++- T-2037+- T-1825 (separate dashboard terminal-filter behavior; intentionally unchanged)
CI is successful. The established macOS baseline is 1,742 passed / 0 failed. The established iOS baseline is 1,230 passed / 3 failed; those three failures predate this head and are not a release blocker for this patch.
The exact head was independently reviewed with no defects, the candidate diff is whitespace-clean, and GitHub reports zero unresolved review threads.