Independent audit of PR #191 against origin/main at 724acb1
git diff origin/main...HEAD after fetching origin/main.Ready to push
No must-fix correctness, security, data-integrity, build, or test issues were found in the exact origin/main...HEAD diff. The implementation fails closed during ambiguity and converges synced duplicates without deleting records or changing task assignments.
Shown verbatim — the markdown the author wrote, unmodified.
## Summary - fail closed when project-scoped milestone name lookup finds multiple CloudKit-synced records - report ambiguity through every MCP and App Intent name-based caller (`AMBIGUOUS_MILESTONE` for intents) - deterministically reconcile synced duplicates without deleting milestones or changing task assignments - observe late CloudKit imports so reconciliation runs after data arrives, not only at lifecycle boundaries ## Root cause Milestones use UUID CloudKit record identifiers, so two disconnected devices can each create the same normalized name without a record conflict. SwiftData with CloudKit cannot enforce a unique attribute, and `findByName` previously selected the first matching row.
e3c2b2f T-1938: Add investigation and failing milestone sync tests 5b3b802 T-1938: Reconcile cross-device milestone names 2f21fe0 Fix PR review build failures 724acb1 Use stable milestone name matching in MCP filters Two devices could independently create the same milestone name while offline. CloudKit would later keep both records because their UUIDs differ. Previously, Transit could silently choose one record when an automation referred to that name. This change refuses to guess, tells automation that the name is ambiguous, and later renames the extra milestone records so both users' data and task assignments are preserved.
The service now normalizes names using fixed POSIX case folding and treats lookup as a 0/1/many contract: nil, one milestone, or ambiguousName. App Intents and MCP mutation paths map that error explicitly. A reconciler groups milestones by project and normalized name, keeps the oldest deterministic winner, and assigns collision-safe UUID-derived names to losers before one rollback-safe save.
This is an eventual-consistency repair for a uniqueness invariant that SwiftData/CloudKit cannot enforce at the schema layer. Reconciliation is main-actor synchronous, avoids yielding between the clean-context guard and save, uses creation date plus UUID as a total deterministic ordering, seeds reserved names from every milestone in the project, and is idempotent. A sorted SwiftUI query fingerprint catches late imports; lifecycle and connectivity promotion paths provide additional retries.
Transit/Transit/Services/MilestoneService.swift
Why it matters. Name-based mutations must never select an arbitrary CloudKit duplicate.
What to look at. Review the throwing findByName contract, POSIX-normalized matching, and promotion-time reconciliation call.
Transit/Transit/Services/MilestoneNameReconciler.swift
Why it matters. Synced duplicates must converge identically on every device without destroying tasks, descriptions, or status.
What to look at. Inspect grouping, winner ordering, reserved-name collision handling, the clean-context guard, and rollback-safe save.
Transit/Transit/Views/ScenePhaseModifier.swift
Why it matters. CloudKit imports can arrive after startup or foreground maintenance has already run.
What to look at. Review the sorted milestone fingerprint observer and the existing startup/foreground promotion hooks.
Transit/Transit/Intents/IntentHelpers.swift
Why it matters. Every name-based automation surface must reject ambiguity before mutating a task or milestone.
What to look at. Trace ambiguousName handling through CreateTaskIntent, IntentHelpers, TaskUpdateValidator, IntentError, and MCPToolHandler.
Transit/TransitTests/MilestoneCrossDeviceUniquenessTests.swift
Why it matters. The reported bug only appears when independent contexts bypass the normal local uniqueness check, as CloudKit imports do.
What to look at. Inspect the two-context setup and assertions for ambiguity, record/task preservation, deterministic rename, idempotence, and MCP rejection.
en_US_POSIX case folding gives devices with different locale settings the same equivalence relation.Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c4018c2..b958c3b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- 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. - Task mutation surfaces no longer hide duplicate task display IDs behind a not-found error (T-1837). `TaskService.findByDisplayID` already threw `duplicateDisplayID` when several tasks share a `permanentDisplayId` (possible under CloudKit, which cannot enforce uniqueness), but MCP `update_task_status` / `update_task` / `add_comment`, `UpdateStatusIntent`, `IntentHelpers.resolveTask`, and the visual `AddCommentIntent` all collapsed it into `TASK_NOT_FOUND` / "Provide either displayId or taskId", so operators could not tell missing data from store corruption needing display-ID maintenance. Duplicates now report `INTERNAL_ERROR` with the hint `Duplicate task identifier for displayId N` (App Intents — matching the milestone paths and the T-1097 `QueryTasksIntent` fix) or an `isError` result reading `Duplicate task identifier detected for displayId N` (MCP — matching the milestone tools). MCP `query_tasks` uses the same wording instead of leaking `Lookup failed: duplicateDisplayID`. No new error code was introduced. The three MCP call sites now share `resolveTaskArgument`, `UpdateStatusIntent` routes through `IntentHelpers.resolveTask` instead of its own copy of the mapping, and `VisualIntentError` gains a `duplicateIdentifier` case (code `INTERNAL_ERROR`). Regression coverage in `DuplicateTaskDisplayIDIntentTests` and `MCPDuplicateTaskDisplayIDTests`. - The MCP `add_comment` handler now distinguishes a missing required field from a present-but-non-string one for `content` and `authorName` (T-1579). Previously `guard let value = args[key] as? String, !value.isEmpty` collapsed numbers, booleans, arrays, and null into `Missing required argument: content` / `Missing required argument: authorName`, misleading callers into thinking they omitted a field they actually supplied with the wrong type. A present non-string value now returns the field-specific `content must be a string` / `authorName must be a string` before any task resolution or comment creation, matching the hardened pattern used by `update_task_status` after T-1205. Extracted a `requiredString(_:key:)` helper to keep `handleAddComment` within the cyclomatic-complexity limit. Regression coverage added in `MCPAddCommentTypeValidationTests` (numeric/boolean/array/null `content` and `authorName`). - `IntentHelpers.resolveMilestone` now rejects a present-but-non-string `name` identifier (number, boolean, null, array) with `INVALID_INPUT` and the field-specific hint `name must be a string`, instead of treating the failed `as? String` cast as an absent identifier and returning the generic `Provide displayId, milestoneId, or name with project` hint (T-1572). This only affects the `UpdateMilestoneIntent` name+project identifier path; no update fields are applied when the identifier is malformed. The MCP `update_milestone` tool is unaffected (it does not support name-based identification). Regression tests added to `MilestoneRenameTypeValidationTests`.
diff --git a/CLAUDE.md b/CLAUDE.mdindex 21d1817..90dd7a2 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -96,7 +96,7 @@ All business logic lives in `Services/`, not in views: - **DisplayIDAllocator** — CloudKit counter with optimistic locking, provisional ID fallback when offline. Separate instances for tasks and milestones (different counter records). - **TaskService** (`@MainActor @Observable`) — task CRUD, status changes, abandon/restore. Typed `Error` enum. - **ProjectService** (`@MainActor @Observable`) — project CRUD, case-insensitive name lookup with ambiguity detection-- **MilestoneService** (`@MainActor @Observable`) — milestone CRUD, status changes, task assignment validation (project match), name uniqueness within project+- **MilestoneService** (`@MainActor @Observable`) — milestone CRUD, status changes, task assignment validation (project match), fail-closed name lookup, and deterministic post-sync duplicate-name reconciliation - **CommentService** (`@MainActor @Observable`) — comment CRUD on tasks, resolves task references across contexts - **SyncManager** — CloudKit sync preference via UserDefaults; toggle takes effect on next launch - **ContainerFactory** — creates ModelContainer with graceful fallback to in-memory on error@@ -125,7 +125,7 @@ Intents exposed as Shortcuts, each accepting/returning structured JSON via `@Par - **AddCommentIntent** — add comment to a task - **GenerateReportIntent** — generate markdown report of completed/abandoned tasks -Error responses are JSON-encoded in the return string (not thrown) so CLI callers get parseable output. Error codes: `TASK_NOT_FOUND`, `PROJECT_NOT_FOUND`, `AMBIGUOUS_PROJECT`, `INVALID_STATUS`, `INVALID_TYPE`, `INVALID_PRIORITY`, `INVALID_INPUT`, `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `MILESTONE_PROJECT_MISMATCH`, `INTERNAL_ERROR`.+Error responses are JSON-encoded in the return string (not thrown) so CLI callers get parseable output. Error codes: `TASK_NOT_FOUND`, `PROJECT_NOT_FOUND`, `AMBIGUOUS_PROJECT`, `INVALID_STATUS`, `INVALID_TYPE`, `INVALID_PRIORITY`, `INVALID_INPUT`, `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `AMBIGUOUS_MILESTONE`, `MILESTONE_PROJECT_MISMATCH`, `INTERNAL_ERROR`. **Visual intents** (in `Intents/Visual/`) use native App Intent entities and queries for Shortcuts UI integration: `AddTaskIntent`, `FindTasksIntent`.
diff --git a/README.md b/README.mdindex a31fe92..e209ee0 100644--- a/README.md+++ b/README.md@@ -60,7 +60,7 @@ Transit exposes App Intents accessible via the Shortcuts app or `shortcuts run` - **Transit: Add Comment** — add a comment to a task - **Transit: Generate Report** — produce a markdown report of completed tasks -All intents accept a JSON string input and return a JSON string response, including structured error codes (`TASK_NOT_FOUND`, `PROJECT_NOT_FOUND`, `AMBIGUOUS_PROJECT`, `INVALID_STATUS`, `INVALID_TYPE`, `INVALID_INPUT`, `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `MILESTONE_PROJECT_MISMATCH`, `INTERNAL_ERROR`).+All intents accept a JSON string input and return a JSON string response, including structured error codes (`TASK_NOT_FOUND`, `PROJECT_NOT_FOUND`, `AMBIGUOUS_PROJECT`, `INVALID_STATUS`, `INVALID_TYPE`, `INVALID_INPUT`, `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `AMBIGUOUS_MILESTONE`, `MILESTONE_PROJECT_MISMATCH`, `INTERNAL_ERROR`). ## MCP Server (macOS)
diff --git a/Transit/Transit/Intents/CreateTaskIntent.swift b/Transit/Transit/Intents/CreateTaskIntent.swiftindex 577713a..8cdf6c6 100644--- a/Transit/Transit/Intents/CreateTaskIntent.swift+++ b/Transit/Transit/Intents/CreateTaskIntent.swift@@ -214,28 +214,59 @@ struct CreateTaskIntent: AppIntent { } if let milestoneDisplayId {- do {- let milestone = try milestoneService.findByDisplayID(milestoneDisplayId)- guard milestone.project?.id == project.id else {- return (nil, IntentHelpers.mapMilestoneError(.projectMismatch).json)- }- return (milestone, nil)- } catch let error as MilestoneService.Error {- return (nil, IntentHelpers.mapMilestoneError(error).json)- } catch {- return (nil, IntentError.milestoneNotFound(- hint: "No milestone with displayId \(milestoneDisplayId)"- ).json)+ return resolveMilestone(+ displayId: milestoneDisplayId, in: project, using: milestoneService+ )+ }+ if let milestoneName {+ return resolveMilestone(named: milestoneName, in: project, using: milestoneService)+ }+ return (nil, nil)+ }++ @MainActor+ private static func resolveMilestone(+ displayId: Int,+ in project: Project,+ using milestoneService: MilestoneService+ ) -> (milestone: Milestone?, error: String?) {+ do {+ let milestone = try milestoneService.findByDisplayID(displayId)+ guard milestone.project?.id == project.id else {+ return (nil, IntentHelpers.mapMilestoneError(.projectMismatch).json) }- } else if let milestoneName {- guard let milestone = milestoneService.findByName(milestoneName, in: project) else {+ return (milestone, nil)+ } catch let error as MilestoneService.Error {+ return (nil, IntentHelpers.mapMilestoneError(error).json)+ } catch {+ return (nil, IntentError.milestoneNotFound(+ hint: "No milestone with displayId \(displayId)"+ ).json)+ }+ }++ @MainActor+ private static func resolveMilestone(+ named name: String,+ in project: Project,+ using milestoneService: MilestoneService+ ) -> (milestone: Milestone?, error: String?) {+ do {+ guard let milestone = try milestoneService.findByName(name, in: project) else { return (nil, IntentError.milestoneNotFound(- hint: "No milestone named '\(milestoneName)' in project '\(project.name)'"+ hint: "No milestone named '\(name)' in project '\(project.name)'" ).json) } return (milestone, nil)+ } catch MilestoneService.Error.ambiguousName {+ return (nil, IntentError.ambiguousMilestone(+ hint: "Multiple milestones named '\(name)' exist in project '\(project.name)'"+ ).json)+ } catch {+ return (nil, IntentError.internalError(+ hint: "Failed to look up milestone: \(error)"+ ).json) }- return (nil, nil) } /// Parses the optional `priority` field. Absent -> `.medium`;
diff --git a/Transit/Transit/Intents/IntentError.swift b/Transit/Transit/Intents/IntentError.swiftindex ca92786..64b41ce 100644--- a/Transit/Transit/Intents/IntentError.swift+++ b/Transit/Transit/Intents/IntentError.swift@@ -10,6 +10,7 @@ nonisolated enum IntentError: Error { case invalidInput(hint: String) case milestoneNotFound(hint: String) case duplicateMilestoneName(hint: String)+ case ambiguousMilestone(hint: String) case milestoneProjectMismatch(hint: String) case internalError(hint: String) @@ -24,6 +25,7 @@ nonisolated enum IntentError: Error { case .invalidInput: "INVALID_INPUT" case .milestoneNotFound: "MILESTONE_NOT_FOUND" case .duplicateMilestoneName: "DUPLICATE_MILESTONE_NAME"+ case .ambiguousMilestone: "AMBIGUOUS_MILESTONE" case .milestoneProjectMismatch: "MILESTONE_PROJECT_MISMATCH" case .internalError: "INTERNAL_ERROR" }@@ -40,6 +42,7 @@ nonisolated enum IntentError: Error { .invalidInput(let hint), .milestoneNotFound(let hint), .duplicateMilestoneName(let hint),+ .ambiguousMilestone(let hint), .milestoneProjectMismatch(let hint), .internalError(let hint): hint
diff --git a/Transit/Transit/Intents/IntentHelpers.swift b/Transit/Transit/Intents/IntentHelpers.swiftindex 8992fa1..770a206 100644--- a/Transit/Transit/Intents/IntentHelpers.swift+++ b/Transit/Transit/Intents/IntentHelpers.swift@@ -130,6 +130,8 @@ nonisolated enum IntentHelpers { .milestoneNotFound(hint: "No matching milestone found") case .duplicateName: .duplicateMilestoneName(hint: "A milestone with this name already exists in the project")+ case .ambiguousName:+ .ambiguousMilestone(hint: "Multiple milestones with this name exist in the project") case .duplicateDisplayID: .internalError(hint: "A duplicate milestone identifier was detected") case .projectRequired:@@ -282,12 +284,20 @@ nonisolated enum IntentHelpers { } return .failure(.invalidInput(hint: "Project required for name-based lookup")) }- guard let found = milestoneService.findByName(name, in: project) else {- return .failure(.milestoneNotFound(- hint: "No milestone named '\(name)' in project '\(project.name)'"+ do {+ guard let found = try milestoneService.findByName(name, in: project) else {+ return .failure(.milestoneNotFound(+ hint: "No milestone named '\(name)' in project '\(project.name)'"+ ))+ }+ return .success(found)+ } catch MilestoneService.Error.ambiguousName {+ return .failure(.ambiguousMilestone(+ hint: "Multiple milestones named '\(name)' exist in project '\(project.name)'" ))+ } catch {+ return .failure(.internalError(hint: "Failed to look up milestone: \(error)")) }- return .success(found) } } @@ -416,25 +426,39 @@ nonisolated enum IntentHelpers { ).json } } else if let milestoneName = json["milestone"] as? String {- guard let project = task.project else {- return IntentError.invalidInput(- hint: "Task must belong to a project before assigning a milestone"- ).json- }- guard let milestone = milestoneService.findByName(milestoneName, in: project) else {+ return assignMilestone(named: milestoneName, to: task, using: milestoneService)+ }+ return nil+ }++ @MainActor+ private static func assignMilestone(+ named name: String,+ to task: TransitTask,+ using milestoneService: MilestoneService+ ) -> String? {+ guard let project = task.project else {+ return IntentError.invalidInput(+ hint: "Task must belong to a project before assigning a milestone"+ ).json+ }+ do {+ guard let milestone = try milestoneService.findByName(name, in: project) else { return IntentError.milestoneNotFound(- hint: "No milestone named '\(milestoneName)' in project '\(project.name)'"+ hint: "No milestone named '\(name)' in project '\(project.name)'" ).json }- do {- try milestoneService.setMilestone(milestone, on: task)- } catch let error as MilestoneService.Error {- return mapMilestoneError(error).json- } catch {- return IntentError.invalidInput(hint: "Failed to assign milestone").json- }+ try milestoneService.setMilestone(milestone, on: task)+ return nil+ } catch MilestoneService.Error.ambiguousName {+ return IntentError.ambiguousMilestone(+ hint: "Multiple milestones named '\(name)' exist in project '\(project.name)'"+ ).json+ } catch let error as MilestoneService.Error {+ return mapMilestoneError(error).json+ } catch {+ return IntentError.invalidInput(hint: "Failed to assign milestone").json }- return nil } } // swiftlint:enable type_body_length file_length
diff --git a/Transit/Transit/Intents/TaskUpdateValidator.swift b/Transit/Transit/Intents/TaskUpdateValidator.swiftindex 5c7a041..9f1bce1 100644--- a/Transit/Transit/Intents/TaskUpdateValidator.swift+++ b/Transit/Transit/Intents/TaskUpdateValidator.swift@@ -304,12 +304,20 @@ enum TaskUpdateValidator { guard let project = task.project else { return .failure(.projectRequiredForMilestone) }- guard let milestone = milestoneService.findByName(name, in: project) else {- return .failure(.milestoneNotFound(- message: "No milestone named '\(name)' in project '\(project.name)'"+ do {+ guard let milestone = try milestoneService.findByName(name, in: project) else {+ return .failure(.milestoneNotFound(+ message: "No milestone named '\(name)' in project '\(project.name)'"+ ))+ }+ return projectMatched(milestone: milestone, task: task)+ } catch MilestoneService.Error.ambiguousName {+ return .failure(.ambiguousMilestoneName(+ message: "Multiple milestones named '\(name)' exist in project '\(project.name)'" ))+ } catch {+ return .failure(.invalidInput("Failed to look up milestone: \(error)")) }- return projectMatched(milestone: milestone, task: task) } /// Validates that a resolved milestone's project matches the task's project.@@ -396,6 +404,7 @@ enum TaskUpdateValidationError: Error { case invalidInput(String) case invalidPriority(String) case milestoneNotFound(message: String)+ case ambiguousMilestoneName(message: String) case duplicateMilestoneDisplayID(message: String) case milestoneProjectMismatch case projectRequiredForMilestone@@ -408,6 +417,7 @@ enum TaskUpdateValidationError: Error { case .invalidInput(let message), .invalidPriority(let message), .milestoneNotFound(let message),+ .ambiguousMilestoneName(let message), .duplicateMilestoneDisplayID(let message): return message case .milestoneProjectMismatch:@@ -428,6 +438,8 @@ enum TaskUpdateValidationError: Error { return .invalidPriority(hint: hint) case .milestoneNotFound(let message): return .milestoneNotFound(hint: message)+ case .ambiguousMilestoneName(let message):+ return .ambiguousMilestone(hint: message) case .duplicateMilestoneDisplayID(let message): return .internalError(hint: message) case .milestoneProjectMismatch:
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex 5cafd2b..159873a 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -291,10 +291,18 @@ final class MCPToolHandler { guard let milestoneName = args["milestone"] as? String else { return errorResult("milestone must be a string") }- guard let milestone = milestoneService.findByName(milestoneName, in: project) else {- return errorResult("No milestone named '\(milestoneName)' in project '\(project.name)'")+ do {+ guard let milestone = try milestoneService.findByName(milestoneName, in: project) else {+ return errorResult("No milestone named '\(milestoneName)' in project '\(project.name)'")+ }+ resolvedMilestone = milestone+ } catch MilestoneService.Error.ambiguousName {+ return errorResult(+ "Multiple milestones named '\(milestoneName)' exist in project '\(project.name)'"+ )+ } catch {+ return errorResult("Failed to look up milestone: \(error)") }- resolvedMilestone = milestone } // Reject non-string description: as? String silently drops@@ -456,18 +464,30 @@ final class MCPToolHandler { !milestoneName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let projectFilter { // Scoped to a single project — at most one milestone matches- if case .success(let project) = projectService.findProject(id: projectFilter),- let milestone = milestoneService.findByName(milestoneName, in: project) {- milestoneFilter = [milestone.id]- } else {+ guard case .success(let project) = projectService.findProject(id: projectFilter) else { return textResult(IntentHelpers.encodeJSONArray([])) }+ do {+ guard let milestone = try milestoneService.findByName(milestoneName, in: project) else {+ return textResult(IntentHelpers.encodeJSONArray([]))+ }+ milestoneFilter = [milestone.id]+ } catch MilestoneService.Error.ambiguousName {+ return errorResult(+ "Multiple milestones named '\(milestoneName)' exist in project '\(project.name)'"+ )+ } catch {+ return errorResult("Failed to look up milestone: \(error)")+ } } else { // No project filter — collect ALL milestones with this name across projects let allMilestones = (try? milestoneService.fetchAllMilestones()) ?? [] let matchingIds = Set( allMilestones- .filter { $0.name.localizedCaseInsensitiveCompare(milestoneName) == .orderedSame }+ .filter {+ MilestoneNamePolicy.normalized($0.name)+ == MilestoneNamePolicy.normalized(milestoneName)+ } .map(\.id) ) if matchingIds.isEmpty {
diff --git a/Transit/Transit/Services/MilestoneNameReconciler.swift b/Transit/Transit/Services/MilestoneNameReconciler.swiftnew file mode 100644index 0000000..cf37ed1--- /dev/null+++ b/Transit/Transit/Services/MilestoneNameReconciler.swift@@ -0,0 +1,86 @@+import Foundation+import SwiftData++/// Shared, locale-stable milestone name semantics. CloudKit peers may use+/// different user locales, so invariant checks and reconciliation must not.+@MainActor+enum MilestoneNamePolicy {+ static func normalized(_ name: String) -> String {+ name.trimmingCharacters(in: .whitespacesAndNewlines)+ .folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))+ }++ static func precedesForReconciliation(_ lhs: Milestone, _ rhs: Milestone) -> Bool {+ if lhs.creationDate != rhs.creationDate {+ return lhs.creationDate < rhs.creationDate+ }+ return lhs.id.uuidString < rhs.id.uuidString+ }+}++/// Repairs duplicate names imported from other CloudKit devices without+/// deleting records or changing their task relationships.+@MainActor+struct MilestoneNameReconciler {+ private let modelContext: ModelContext++ init(modelContext: ModelContext) {+ self.modelContext = modelContext+ }++ @discardableResult+ func reconcile() throws -> Int {+ // Saving this shared context would also commit unrelated edits. Defer until+ // a later lifecycle trigger if another workflow currently owns changes.+ guard !modelContext.hasChanges else { return 0 }++ let milestones = try modelContext.fetch(FetchDescriptor<Milestone>())+ let projectGroups = Dictionary(grouping: milestones) { $0.project?.id }+ var renamedCount = 0+ for projectMilestones in projectGroups.values {+ renamedCount += renameDuplicates(in: projectMilestones)+ }++ if renamedCount > 0 {+ try modelContext.saveOrRollback()+ }+ return renamedCount+ }++ private func renameDuplicates(in milestones: [Milestone]) -> Int {+ var groups: [String: [Milestone]] = [:]+ for milestone in milestones where milestone.project != nil {+ groups[MilestoneNamePolicy.normalized(milestone.name), default: []].append(milestone)+ }+ var reservedNames = Set(milestones.map { MilestoneNamePolicy.normalized($0.name) })+ var renamedCount = 0++ for nameKey in groups.keys.sorted() {+ guard let matches = groups[nameKey], matches.count > 1 else { continue }+ let ordered = matches.sorted(by: MilestoneNamePolicy.precedesForReconciliation)+ guard let winner = ordered.first else { continue }+ for duplicate in ordered.dropFirst() {+ let candidate = uniqueName(for: duplicate, winner: winner, reserved: reservedNames)+ duplicate.name = candidate+ reservedNames.insert(MilestoneNamePolicy.normalized(candidate))+ renamedCount += 1+ }+ }+ return renamedCount+ }++ private func uniqueName(+ for duplicate: Milestone,+ winner: Milestone,+ reserved: Set<String>+ ) -> String {+ let base = "\(winner.name) (Duplicate \(duplicate.id.uuidString))"+ var candidate = base+ var collisionSuffix = 2+ while reserved.contains(MilestoneNamePolicy.normalized(candidate)) {+ candidate = "\(base)-\(collisionSuffix)"+ collisionSuffix += 1+ }+ return candidate+ }+}
diff --git a/Transit/Transit/Services/MilestoneService.swift b/Transit/Transit/Services/MilestoneService.swiftindex d436968..ab6e4f5 100644--- a/Transit/Transit/Services/MilestoneService.swift+++ b/Transit/Transit/Services/MilestoneService.swift@@ -186,6 +186,17 @@ final class MilestoneService { isPromotingMilestones = true defer { isPromotingMilestones = false } + // CloudKit merges UUID-distinct records rather than enforcing the+ // project/name invariant. Repair duplicates imported since the last+ // lifecycle pass before doing display-ID maintenance (T-1938).+ do {+ try reconcileDuplicateNames()+ } catch {+ // A later launch/foreground/connectivity pass retries. Do not continue+ // with an unreadable or unsavable store and mask the maintenance failure.+ return+ }+ let descriptor = FetchDescriptor<Milestone>( predicate: #Predicate { $0.permanentDisplayId == nil }, sortBy: [SortDescriptor(\.creationDate, order: .forward)]@@ -242,16 +253,38 @@ final class MilestoneService { return first } - func findByName(_ name: String, in project: Project) -> Milestone? {- let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)+ /// Finds exactly one case-insensitive name match in a project.+ ///+ /// Duplicate names can arrive through CloudKit even though local creation is+ /// guarded. Never select `.first`: name-addressed callers must report the+ /// ambiguity and require a stable UUID/display ID until reconciliation runs.+ func findByName(_ name: String, in project: Project) throws -> Milestone? {+ let normalized = MilestoneNamePolicy.normalized(name) let projectID = project.id let descriptor = FetchDescriptor<Milestone>( predicate: #Predicate { $0.project?.id == projectID } )- let milestones = (try? modelContext.fetch(descriptor)) ?? []- return milestones.first {- $0.name.localizedCaseInsensitiveCompare(trimmed) == .orderedSame+ let matches = try modelContext.fetch(descriptor).filter {+ MilestoneNamePolicy.normalized($0.name) == normalized }+ guard let match = matches.first else { return nil }+ guard matches.count == 1 else { throw Error.ambiguousName }+ return match+ }++ // MARK: - Post-sync maintenance++ /// Restores project-scoped name uniqueness after CloudKit imports records+ /// independently created on different devices.+ ///+ /// The oldest record keeps its original name (UUID breaks timestamp ties).+ /// Every other record receives a deterministic UUID-derived suffix. Renaming,+ /// rather than deleting or merging, preserves descriptions, statuses, display+ /// IDs, and task assignments. The UUID also lets every device converge on the+ /// same names independently. Returns the number of records renamed.+ @discardableResult+ func reconcileDuplicateNames() throws -> Int {+ try MilestoneNameReconciler(modelContext: modelContext).reconcile() } func milestonesForProject(_ project: Project, status: MilestoneStatus? = nil) -> [Milestone] {@@ -299,7 +332,7 @@ final class MilestoneService { func milestoneNameExists( _ name: String, in project: Project, excluding milestoneId: UUID? = nil ) throws -> Bool {- let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)+ let normalized = MilestoneNamePolicy.normalized(name) let projectID = project.id let descriptor = FetchDescriptor<Milestone>( predicate: #Predicate { $0.project?.id == projectID }@@ -307,9 +340,10 @@ final class MilestoneService { let milestones = try fetcher.fetch(descriptor) return milestones.contains { milestone in if let milestoneId, milestone.id == milestoneId { return false }- return milestone.name.localizedCaseInsensitiveCompare(trimmed) == .orderedSame+ return MilestoneNamePolicy.normalized(milestone.name) == normalized } }+ } // MARK: - Errors@@ -320,6 +354,7 @@ extension MilestoneService { case invalidName case milestoneNotFound case duplicateName+ case ambiguousName case duplicateDisplayID case projectRequired case projectMismatch@@ -332,6 +367,8 @@ extension MilestoneService { "The specified milestone could not be found." case .duplicateName: "A milestone with this name already exists in the project."+ case .ambiguousName:+ "Multiple milestones with this name exist in the project." case .duplicateDisplayID: "A duplicate milestone identifier was detected." case .projectRequired:
diff --git a/Transit/Transit/Views/ScenePhaseModifier.swift b/Transit/Transit/Views/ScenePhaseModifier.swiftindex fadeee7..865954d 100644--- a/Transit/Transit/Views/ScenePhaseModifier.swift+++ b/Transit/Transit/Views/ScenePhaseModifier.swift@@ -5,11 +5,20 @@ import SwiftUI /// triggers display ID promotion on app launch and return to foreground. struct ScenePhaseModifier: ViewModifier { @Environment(\.scenePhase) private var scenePhase+ @Query private var milestones: [Milestone] let displayIDAllocator: DisplayIDAllocator let milestoneService: MilestoneService let modelContext: ModelContext + private var milestoneNameFingerprint: String {+ milestones.map {+ "\($0.id.uuidString)|\($0.project?.id.uuidString ?? "orphan")|\($0.name)"+ }+ .sorted()+ .joined(separator: "\u{1F}")+ }+ func body(content: Content) -> some View { content .task {@@ -24,5 +33,10 @@ struct ScenePhaseModifier: ViewModifier { } } }+ .onChange(of: milestoneNameFingerprint) {+ // CloudKit imports may complete after launch/foreground hooks. Query+ // observation provides the post-sync pass; reconciliation is idempotent.+ try? milestoneService.reconcileDuplicateNames()+ } } }
diff --git a/Transit/TransitTests/IntentErrorTests.swift b/Transit/TransitTests/IntentErrorTests.swiftindex 4af9d9e..62ecca0 100644--- a/Transit/TransitTests/IntentErrorTests.swift+++ b/Transit/TransitTests/IntentErrorTests.swift@@ -42,6 +42,11 @@ struct IntentErrorTests { #expect(error.code == "INVALID_INPUT") } + @Test func ambiguousMilestoneCode() {+ let error = IntentError.ambiguousMilestone(hint: "Multiple milestones match")+ #expect(error.code == "AMBIGUOUS_MILESTONE")+ }+ // MARK: - Hint Property @Test func hintReturnsAssociatedValue() {
diff --git a/Transit/TransitTests/MilestoneCrossDeviceUniquenessTests.swift b/Transit/TransitTests/MilestoneCrossDeviceUniquenessTests.swiftnew file mode 100644index 0000000..d744cd2--- /dev/null+++ b/Transit/TransitTests/MilestoneCrossDeviceUniquenessTests.swift@@ -0,0 +1,176 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// Regression coverage for T-1938. CloudKit identifies milestones by UUID, so two+/// disconnected devices can each create the same project-scoped name and later sync+/// both records into one store. These tests simulate that import with independent+/// contexts sharing one container; they intentionally bypass `createMilestone`, just+/// as CloudKit import bypasses service-layer validation.+@MainActor @Suite(.serialized)+struct MilestoneCrossDeviceUniquenessTests {++ private struct Environment {+ let container: ModelContainer+ let projectID: UUID+ let firstMilestoneID: UUID+ let secondMilestoneID: UUID+ }++ private func makeTask(+ named name: String,+ project: Project,+ milestone: Milestone,+ displayID: Int+ ) -> TransitTask {+ let task = TransitTask(+ name: name,+ type: .feature,+ project: project,+ displayID: .permanent(displayID)+ )+ task.milestone = milestone+ return task+ }++ private func makeSyncedDuplicateEnvironment() throws -> Environment {+ let container = try TestModelContainer.newContainer()++ let firstDevice = ModelContext(container)+ let project = Project(+ name: "Transit",+ description: "Test project",+ gitRepo: nil,+ colorHex: "#FF0000"+ )+ firstDevice.insert(project)+ let first = Milestone(+ name: "Beta",+ description: "Created on device A",+ project: project,+ displayID: .permanent(1)+ )+ first.creationDate = Date(timeIntervalSince1970: 1)+ firstDevice.insert(first)+ firstDevice.insert(makeTask(+ named: "Device A task", project: project, milestone: first, displayID: 10+ ))+ try firstDevice.save()++ let secondDevice = ModelContext(container)+ let projectID = project.id+ let projectDescriptor = FetchDescriptor<Project>(+ predicate: #Predicate { $0.id == projectID }+ )+ let importedProject = try #require(try secondDevice.fetch(projectDescriptor).first)+ let second = Milestone(+ name: "beta",+ description: "Created on device B",+ project: importedProject,+ displayID: .permanent(2)+ )+ second.creationDate = Date(timeIntervalSince1970: 2)+ secondDevice.insert(second)+ secondDevice.insert(makeTask(+ named: "Device B task", project: importedProject, milestone: second, displayID: 11+ ))+ try secondDevice.save()++ return Environment(+ container: container,+ projectID: project.id,+ firstMilestoneID: first.id,+ secondMilestoneID: second.id+ )+ }++ private func makeService(in context: ModelContext) -> MilestoneService {+ MilestoneService(+ modelContext: context,+ displayIDAllocator: DisplayIDAllocator(+ store: InMemoryCounterStore(initialNextDisplayID: 3),+ isCloudSyncActive: true+ )+ )+ }++ @Test func nameLookupDoesNotChooseAnArbitrarySyncedDuplicate() throws {+ let environment = try makeSyncedDuplicateEnvironment()+ let receivingDevice = ModelContext(environment.container)+ let service = makeService(in: receivingDevice)+ let projectID = environment.projectID+ let descriptor = FetchDescriptor<Project>(predicate: #Predicate { $0.id == projectID })+ let project = try #require(try receivingDevice.fetch(descriptor).first)++ #expect(throws: MilestoneService.Error.ambiguousName) {+ try service.findByName("BETA", in: project)+ }+ }++ @Test func postSyncMaintenanceReconcilesNamesWithoutDeletingRecords() async throws {+ let environment = try makeSyncedDuplicateEnvironment()+ let receivingDevice = ModelContext(environment.container)+ let service = makeService(in: receivingDevice)++ // App launch, foregrounding, and connectivity restoration already invoke this+ // hook after sync-related work. It must also repair UUID-distinct name conflicts.+ await service.promoteProvisionalMilestones()++ let milestones = try receivingDevice.fetch(FetchDescriptor<Milestone>())+ let normalizedNames = Set(milestones.map { $0.name.lowercased() })+ #expect(milestones.count == 2, "Reconciliation must preserve both synced records")+ #expect(normalizedNames.count == 2, "Reconciliation must restore unique project-scoped names")+ #expect(Set(milestones.map(\.id)) == [+ environment.firstMilestoneID,+ environment.secondMilestoneID+ ])+ #expect(+ milestones.first { $0.id == environment.firstMilestoneID }?.name == "Beta",+ "The deterministic oldest winner must keep its original name"+ )++ let tasks = try receivingDevice.fetch(FetchDescriptor<TransitTask>())+ #expect(tasks.count == 2)+ #expect(Set(tasks.compactMap { $0.milestone?.id }) == [+ environment.firstMilestoneID,+ environment.secondMilestoneID+ ], "Reconciliation must preserve both task assignments")+ #expect(try service.reconcileDuplicateNames() == 0, "Reconciliation must be idempotent")+ }++ #if os(macOS)+ @Test func mcpCreateTaskReportsAmbiguousMilestoneName() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context, name: "Transit")+ env.context.insert(Milestone(+ name: "Beta",+ description: nil,+ project: project,+ displayID: .permanent(1)+ ))+ env.context.insert(Milestone(+ name: "beta",+ description: nil,+ project: project,+ displayID: .permanent(2)+ ))+ try env.context.save()++ let request = MCPTestHelpers.toolCallRequest(+ tool: "create_task",+ arguments: [+ "name": "Must not be created",+ "type": "feature",+ "project": "Transit",+ "milestone": "BETA"+ ]+ )+ let response = await env.handler.handle(request)++ #expect(try MCPTestHelpers.isError(response))+ #expect(try MCPTestHelpers.errorText(response).contains("Multiple milestones named"))+ #expect(try env.taskService.fetchAllTasks().isEmpty)+ }+ #endif+}
diff --git a/Transit/TransitTests/MilestoneServiceLookupTests.swift b/Transit/TransitTests/MilestoneServiceLookupTests.swiftindex 194c4f4..6f781c9 100644--- a/Transit/TransitTests/MilestoneServiceLookupTests.swift+++ b/Transit/TransitTests/MilestoneServiceLookupTests.swift@@ -85,14 +85,14 @@ struct MilestoneServiceLookupTests { let project = makeProject(in: context) _ = try await service.createMilestone(name: "v1.0", description: nil, project: project) - let found = service.findByName("V1.0", in: project)+ let found = try service.findByName("V1.0", in: project) #expect(found?.name == "v1.0") } @Test func findByNameReturnsNilWhenNotFound() throws { let (service, context) = try makeService() let project = makeProject(in: context)- #expect(service.findByName("nonexistent", in: project) == nil)+ #expect(try service.findByName("nonexistent", in: project) == nil) } // MARK: - milestonesForProject
diff --git a/docs/agent-notes/milestones.md b/docs/agent-notes/milestones.mdindex 0638ca8..18b3408 100644--- a/docs/agent-notes/milestones.md+++ b/docs/agent-notes/milestones.md@@ -7,3 +7,10 @@ - The App Intent path in `Transit/Transit/Intents/UpdateMilestoneIntent.swift` (`applyUpdate`) and the MCP path in `Transit/Transit/MCP/MCPToolHandler.swift` (`applyMilestoneUpdate`) apply the same guard inline because they bypass `MilestoneService.updateStatus` to apply multi-field updates atomically. Keep these three sites in sync. - `Transit/Transit/Reports/ReportLogic.swift` uses `completionDate ?? lastStatusChangeDate` as the milestone's effective completion date, so the no-op guard is what prevents an old done or abandoned milestone from re-entering the current report window on retries. - Mirrors the task-side pattern: `TaskService.updateStatus` already short-circuits when `task.status == newStatus`. No equivalent `MilestoneStatusEngine` exists yet — extract one if the milestone status logic grows beyond the single guard.++## Cross-Device Name Conflicts++- CloudKit milestone records are UUID-keyed, so service-layer creation checks cannot strictly prevent two disconnected devices from creating the same normalized name in one project (T-1938).+- `MilestoneService.findByName` is throwing and must never be changed back to `.first`; `.ambiguousName` maps to `AMBIGUOUS_MILESTONE` for App Intents and an explicit MCP tool error.+- `MilestoneNameReconciler` keeps the oldest record's name (UUID tie-break) and gives other records deterministic full-UUID suffixes. It preserves records and task assignments and is idempotent across devices.+- Reconciliation runs through launch/foreground/connectivity promotion hooks and `ScenePhaseModifier` also observes milestone name changes so CloudKit imports that finish later trigger a true post-sync pass. It defers while the shared context has unsaved changes so it cannot commit an unrelated edit.
diff --git a/specs/bugfixes/cross-device-milestone-name-uniqueness/report.md b/specs/bugfixes/cross-device-milestone-name-uniqueness/report.mdnew file mode 100644index 0000000..fd90715--- /dev/null+++ b/specs/bugfixes/cross-device-milestone-name-uniqueness/report.md@@ -0,0 +1,112 @@+# Bugfix Report: Cross-Device Milestone Name Uniqueness++**Date:** 2026-08-01+**Status:** Fixed+**Ticket:** T-1938++## Description of the Issue++Milestone names are intended to be case-insensitively unique within a project. Local creation checks enforce that rule, including a post-allocation recheck for re-entrant creates in one process, but disconnected CloudKit devices can each create a UUID-distinct milestone with the same name. CloudKit preserves both records when they sync because SwiftData's CloudKit integration cannot express a unique constraint. Name lookup then returns the first fetched record, so assignment and mutation can target an arbitrary milestone.++**Reproduction steps:**+1. On device A, while disconnected from device B's changes, create milestone `Beta` in project P.+2. On device B, create milestone `beta` in the same project before device A's record arrives.+3. Allow both UUID-backed records to sync and perform a name-addressed operation for `BETA`.+4. Observe that one arbitrary matching milestone is selected and no duplicate state is reported or repaired.++**Impact:** High. Name-addressed MCP and App Intent operations may mutate or assign the wrong milestone. The duplicate state persists indefinitely and violates milestone requirement 1.4.++## Investigation Summary++The investigation followed the systematic-debugging workflow:++- **Symptoms examined:** UUID-distinct, case-insensitively equal names in one project after cross-device sync; arbitrary `findByName` resolution; no post-sync repair.+- **Code inspected:** `MilestoneService.createMilestone`, `findByName`, and promotion hooks; all direct `findByName` callers in MCP/App Intents; `ScenePhaseModifier`; milestone requirements/design; T-1764's local race fix; CloudKit and SwiftData constraints.+- **Hypotheses tested:**+ - The local post-allocation check could prevent the state. Ruled out: it only sees records already present in that device's local store.+ - CloudKit would reject one record. Ruled out: milestone UUID is the record identity, so the two writes do not conflict.+ - A SwiftData unique attribute could provide strict prevention. Ruled out: CloudKit-backed SwiftData does not support `@Attribute(.unique)`.+ - Existing display-ID coordination could reserve names. Ruled out: it coordinates one counter record only and has no `(projectID, normalizedName)` identity protocol.++## Discovered Root Cause++The service-layer uniqueness invariant is local, while the data is multi-writer and eventually consistent. Two disconnected stores can each truthfully observe that a name is available, create different UUID records, and later merge both records without a CloudKit conflict. The lookup layer incorrectly assumes the invariant can never be violated and collapses multiple matches with `.first`.++**Defect type:** Distributed race / missing ambiguity handling and reconciliation.++**Five Whys:**+1. Why can a name operation target the wrong milestone? Because `findByName` returns the first matching record.+2. Why can several records match? Because separate devices can create the same normalized name before syncing.+3. Why does CloudKit retain both? Because each milestone uses a different UUID record identifier.+4. Why is there no storage constraint? Because CloudKit-backed SwiftData forbids unique attributes.+5. Why does the bad state persist? Because Transit has neither ambiguity reporting nor post-sync duplicate-name reconciliation.++**Contributing factors:** MainActor serialization and T-1764 protect only one process; eventual consistency creates a separate cross-device race. Strict prevention would require direct CloudKit reservation records and would still need an offline conflict policy.++## Resolution for the Issue++**Changes made:**+- `MilestoneService.findByName` now fetches all normalized project-scoped matches and throws `.ambiguousName` instead of selecting `.first` when several exist.+- All direct MCP and App Intent callers catch ambiguity. App Intents return the distinct `AMBIGUOUS_MILESTONE` code; MCP tools return an explicit multiple-match error and do not mutate data.+- `MilestoneNameReconciler` uses locale-stable normalization, keeps the oldest milestone's original name (UUID tie-break), and renames every loser with a deterministic full-UUID suffix. It preserves descriptions, status, display IDs, records, and task assignments, avoids generated-name collisions, and is idempotent.+- Reconciliation runs through existing launch/foreground/connectivity maintenance and through `ScenePhaseModifier` milestone-name observation, covering CloudKit imports that complete after lifecycle hooks. It defers while the shared context has unrelated unsaved changes.+- Milestone requirements/design/implementation, Decision 10, README, CLAUDE.md, and agent notes document the behavior and new error contract.++**Approach rationale:** Fail-closed lookup removes the immediate wrong-record risk. Deterministic renaming restores the uniqueness invariant without guessing how to merge or delete user data, and every device can independently converge on the same result.++**Alternatives considered:**+- **CloudKit `(projectID, normalizedName)` reservation records** — not chosen because offline creation cannot synchronously reserve a name, this would add a second direct-CloudKit subsystem, and reconciliation would still be required for offline conflicts and existing data.+- **Merge and delete duplicates** — rejected because milestone descriptions, statuses, completion state, and task assignments cannot be merged without potentially losing user intent.+- **Ambiguity reporting only** — rejected because it prevents wrong writes but leaves the invalid state unresolved.++## Regression Test++**Test file:** `Transit/TransitTests/MilestoneCrossDeviceUniquenessTests.swift`++**Test names:**+- `nameLookupDoesNotChooseAnArbitrarySyncedDuplicate`+- `postSyncMaintenanceReconcilesNamesWithoutDeletingRecords`+- `mcpCreateTaskReportsAmbiguousMilestoneName`++**What it verifies:** Independent contexts can simulate a CloudKit-imported duplicate state; lookup does not select arbitrarily; lifecycle maintenance restores unique names without deleting records; MCP rejects ambiguous name assignment without creating a task.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/MilestoneService.swift` | Throw on ambiguous name lookup and invoke reconciliation from lifecycle maintenance |+| `Transit/Transit/Services/MilestoneNameReconciler.swift` | Add deterministic, data-preserving duplicate-name repair and shared normalization |+| `Transit/Transit/Views/ScenePhaseModifier.swift` | Observe imported milestone-name changes and trigger post-sync reconciliation |+| `Transit/Transit/Intents/IntentError.swift` | Add `AMBIGUOUS_MILESTONE` |+| `Transit/Transit/Intents/IntentHelpers.swift` | Propagate ambiguity through shared resolution/assignment callers |+| `Transit/Transit/Intents/CreateTaskIntent.swift` | Reject ambiguous milestone assignment during task creation |+| `Transit/Transit/Intents/TaskUpdateValidator.swift` | Reject ambiguous milestone assignment during task updates |+| `Transit/Transit/MCP/MCPToolHandler.swift` | Reject ambiguity in create/query task name paths |+| `Transit/TransitTests/MilestoneCrossDeviceUniquenessTests.swift` | Add cross-context CloudKit simulation, preservation, idempotence, and MCP regressions |+| `Transit/TransitTests/MilestoneServiceLookupTests.swift` | Adopt the throwing lookup contract |+| `Transit/TransitTests/IntentErrorTests.swift` | Cover the new intent error code |+| `specs/milestones/*`, `README.md`, `CLAUDE.md`, `docs/agent-notes/milestones.md` | Document the behavior, tradeoff, lifecycle, and error contract |++## Verification++**Automated:**+- [x] Regression tests failed before the fix (`xcodebuild ... -only-testing:TransitTests/MilestoneCrossDeviceUniquenessTests`; all three tests failed as expected)+- [x] Targeted regression suite passes after the fix (3/3 tests)+- [x] macOS unit suite passes (`make test-quick`)+- [x] Full iOS simulator suite passes (`make test`)+- [x] UI suite passes (`make test-ui`)+- [x] SwiftLint passes (`make lint`)++## Prevention++- Treat service-layer uniqueness in an eventually consistent store as a best-effort creation guard, not proof that duplicate states are impossible.+- Any name-based lookup over CloudKit data must distinguish zero, one, and multiple matches.+- Reconciliation must preserve user data and choose winners deterministically so every device converges on the same result.++## Related++- T-1764 — closes only the re-entrant single-process create window.+- `specs/milestones/requirements.md` requirement 1.4.+- `specs/bugfixes/concurrent-milestone-name-uniqueness/report.md`.
diff --git a/specs/milestones/decision_log.md b/specs/milestones/decision_log.mdindex b180d88..3507ab7 100644--- a/specs/milestones/decision_log.md+++ b/specs/milestones/decision_log.md@@ -295,3 +295,44 @@ Changing a property to a method touches every call site (~10 files) for no funct - Two ways to format (property and method) — minimal confusion since the property is just sugar ---++## Decision 10: Reconcile Cross-Device Name Conflicts++**Date**: 2026-08-01+**Status**: accepted++### Context++CloudKit-backed SwiftData cannot enforce unique attributes. Two disconnected devices can therefore each create a UUID-distinct milestone with the same normalized name in one project, and CloudKit merges both records without a conflict. Local service checks and MainActor serialization cannot prevent this distributed race.++### Decision++Treat duplicate-name states as valid sync input. Name lookup fails closed when several milestones match. Existing lifecycle sync hooks deterministically keep the oldest record's original name (UUID tie-break) and rename every other match with a UUID-derived suffix, preserving all records and task assignments. Do not add a direct CloudKit name-reservation protocol in this repository scope.++### Rationale++Fail-closed lookup immediately prevents arbitrary mutation. Deterministic renaming lets every device converge without deleting user data or guessing how descriptions, statuses, or task assignments should merge. A reservation record keyed by `(projectID, normalizedName)` would add a second direct-CloudKit subsystem, make offline creation unavailable or provisional, and still need reconciliation for existing data and offline conflicts. That complexity is disproportionate for a single-user app.++### Alternatives Considered++- **CloudKit reservation records**: Atomically claim normalized project/name keys before creation - Rejected because offline creation cannot synchronously reserve a key, direct CloudKit records would require lifecycle and migration machinery, and conflict reconciliation remains necessary.+- **Merge and delete duplicate milestones**: Reassign tasks to one winner and delete the rest - Rejected because status, description, completion, and user intent cannot be merged safely without data loss.+- **Report ambiguity only**: Require manual repair - Rejected because the invalid state would persist and continue breaking name-based automation.++### Consequences++**Positive:**+- Name-based operations never target an arbitrary record.+- Reconciliation is deterministic, idempotent, and preserves milestone/task data.+- No new CloudKit schema or online-only creation dependency is introduced.++**Negative:**+- A duplicate milestone may be automatically renamed with a long UUID suffix.+- Reconciliation is eventual and runs on launch, foreground, connectivity restoration, or observed milestone-name imports rather than as a CloudKit transaction.+- Maintenance defers while the shared context has unsaved changes and retries on a later lifecycle trigger.++### Impact++`MilestoneService`, all name-based MCP/App Intent callers, the lifecycle promotion hook, automation error codes, and cross-context tests implement this policy.++---
diff --git a/specs/milestones/design.md b/specs/milestones/design.mdindex c21d14e..d666844 100644--- a/specs/milestones/design.md+++ b/specs/milestones/design.md@@ -190,7 +190,7 @@ final class MilestoneService { func findByID(_ id: UUID) throws -> Milestone func findByDisplayID(_ displayId: Int) throws -> Milestone- func findByName(_ name: String, in project: Project) -> Milestone?+ func findByName(_ name: String, in project: Project) throws -> Milestone? func milestonesForProject(_ project: Project, status: MilestoneStatus? = nil) -> [Milestone] func milestoneNameExists(_ name: String, in project: Project, excluding: UUID? = nil) -> Bool }@@ -203,10 +203,10 @@ final class MilestoneService { - `updateStatus`: updates `statusRawValue`, `lastStatusChangeDate`, sets/clears `completionDate` based on terminal status. **Throws on save failure with rollback** (same pattern as `TaskService.updateStatus`, per T-150 fix). No StatusEngine needed — the three-state lifecycle is simple enough to inline. - `deleteMilestone`: shows confirmation of affected task count, deletes from context, saves. SwiftData handles nullifying task associations. - `setMilestone(_:on:)`: validates `task.project != nil`, validates `milestone.project?.id == task.project?.id` (throws `.projectMismatch` on mismatch), sets `task.milestone`. All milestone assignment flows (views, MCP, intents) go through this method.-- `promoteProvisionalMilestones()`: queries milestones with `permanentDisplayId == nil`, allocates IDs via the dedicated allocator. Same pattern as `DisplayIDAllocator.promoteProvisionalTasks` but for milestones.+- `promoteProvisionalMilestones()`: first runs deterministic duplicate-name reconciliation for CloudKit-imported conflicts, then queries milestones with `permanentDisplayId == nil` and allocates IDs via the dedicated allocator. Same promotion pattern as `DisplayIDAllocator.promoteProvisionalTasks`. - Lookup methods use `FetchDescriptor` with `#Predicate` on the Milestone side (never traversing `Project.milestones` to-many), same pattern as `TaskService`. -**Known limitation:** Name uniqueness check in `createMilestone` has a TOCTOU window during the async display ID allocation. In a single-user app, concurrent creation of identically-named milestones in the same project is extremely unlikely. Acceptable tradeoff.+**Cross-device uniqueness policy:** Local creation rechecks names after asynchronous display-ID allocation, but CloudKit can still merge UUID-distinct, same-name records created while devices are disconnected. `findByName` therefore throws on multiple matches instead of choosing arbitrarily. Lifecycle sync hooks plus SwiftData milestone-name observation deterministically preserve the oldest name and rename other records with UUID-derived suffixes, retaining all records and task assignments. Strict prevention would require a direct CloudKit `(projectID, normalizedName)` reservation protocol, which cannot guarantee offline availability and would still require reconciliation after conflicts; that coordination is outside the repository-appropriate scope for this single-user app (Decision 10). ### 2.2 DisplayIDAllocator Changes
diff --git a/specs/milestones/implementation.md b/specs/milestones/implementation.mdindex 2eba03b..a78d254 100644--- a/specs/milestones/implementation.md+++ b/specs/milestones/implementation.md@@ -76,11 +76,11 @@ The implementation follows existing Transit patterns throughout: **Display ID allocation**: The `CloudKitCounterStore` (private class inside `DisplayIDAllocator.swift`) was parameterised to accept a `recordName` parameter through the public convenience init. The default `"global-counter"` preserves backward compatibility. Two allocator instances coexist without interference since they operate on different counter records. -**TOCTOU in name uniqueness**: `createMilestone` checks uniqueness before the async display ID allocation. In a concurrent environment, two identical-name milestones could be created simultaneously. Acceptable for a single-user app where the window is negligibly small.+**Name uniqueness under CloudKit**: `createMilestone` checks before and after async display-ID allocation, closing the in-process MainActor race. Cross-device writes remain eventually consistent: disconnected peers can create UUID-distinct records with the same normalized project/name. `findByName` fails closed with `.ambiguousName`, and post-sync lifecycle maintenance deterministically renames duplicate losers with UUID-derived suffixes while preserving records and task assignments (T-1938, Decision 10). **Save semantics in setMilestone**: The `setMilestone(_:on:)` method modifies the object graph but does NOT call `modelContext.save()`. Callers are responsible for saving. MCP handlers and intent helpers save explicitly. The TaskEditView saves in both branches (status change or explicit save). AddTaskSheet explicitly saves after assignment. -**Milestone promotion**: `promoteProvisionalMilestones()` uses the same stop-on-first-failure pattern as task promotion. Milestones created offline get provisional IDs, and promotion runs on connectivity restore (via `ConnectivityMonitor.onRestore`) and app foregrounding (via `ScenePhaseModifier`).+**Milestone promotion and reconciliation**: `promoteProvisionalMilestones()` first reconciles synced duplicate names, then uses the same stop-on-first-failure pattern as task promotion. It defers reconciliation when the shared context has unrelated unsaved edits. Milestones created offline get provisional IDs. Maintenance runs on connectivity restore and app foregrounding; `ScenePhaseModifier` also observes milestone name changes so CloudKit imports that finish after those hooks trigger reconciliation. ### Architecture Impact
diff --git a/specs/milestones/requirements.md b/specs/milestones/requirements.mdindex 26f29c4..9c5e1ae 100644--- a/specs/milestones/requirements.md+++ b/specs/milestones/requirements.md@@ -185,4 +185,4 @@ This feature adds milestones to the data model, services, UI, MCP tools, App Int 6. <a name="13.6"></a>The `Transit: Create Task` intent SHALL accept an optional `milestone` parameter 7. <a name="13.7"></a>The `Transit: Query Tasks` intent SHALL accept an optional `milestone` filter parameter 8. <a name="13.8"></a>Milestone operations SHALL use the same JSON input/output pattern and error codes as existing intents-9. <a name="13.9"></a>Error codes SHALL include: `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `MILESTONE_PROJECT_MISMATCH`+9. <a name="13.9"></a>Error codes SHALL include: `MILESTONE_NOT_FOUND`, `DUPLICATE_MILESTONE_NAME`, `AMBIGUOUS_MILESTONE`, `MILESTONE_PROJECT_MISMATCH`
make test run built and executed production/unit code but reproduced six UI assertion failures in unchanged UI and UI-test files: testClearAll, testEditViewPreservesTaskMilestone, testSettingsHasBackChevron, testSettingsWithNoProjectsShowsCreatePrompt, testTappingGearPushesSettingsView, and testDataMaintenanceGoldenPath. The isolated iOS TransitTests target passed with pipeline failure propagation enabled.QueryTasksIntent.matchesMilestoneFilter still uses localizedCaseInsensitiveCompare, while the analogous MCP match-all filter now uses MilestoneNamePolicy.normalized. This file is outside the PR diff and the path filters tasks across all matching milestone records rather than resolving one mutation target, so it is not a blocker; use the shared policy in a follow-up for cross-surface locale parity.