transit branch T-2080 commits 2 files 8 touched lines +367 / -12

Pre-push review: T-2080 project-name reconciliation

Review of the two commits and eight files that origin/main...HEAD would add to main. The implementation restores project-name uniqueness after eventual CloudKit convergence without deleting records.

At a glance

  • Correct distributed invariant repair: local validation remains best-effort while post-sync reconciliation deterministically restores global uniqueness.
  • Data preserving: no project is merged or deleted; task and milestone relationships remain attached to the same project UUID.
  • Retry coverage: launch, foreground, connectivity restoration, and live project-query changes all provide reconciliation opportunities.
  • Fresh verification: make test-quick, make test, make test-ui, and make lint all exited successfully.

Verdict

Ready to push

No blocking correctness, security, performance, or architecture issues were found. The deterministic repair follows the established milestone pattern, preserves records and relationships by changing names only, fails closed before repair, and passed fresh macOS unit, full iOS simulator, UI, lint, and ownership-guard validation. Two non-blocking documentation/test-coverage suggestions are noted below.

Review findings

3 raised · 0 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Two devices can independently create projects named Transit and transit while offline. CloudKit keeps both because they are different UUID records. This branch notices that state after synchronization, keeps one name, and gives every other project a stable UUID-based suffix.

Why it matters

Automation that looks projects up by name previously became permanently ambiguous. Renaming only the duplicates restores lookup while preserving every project and its tasks and milestones.

Safety

The repair waits when the shared SwiftData context has unsaved edits, so maintenance cannot accidentally commit unrelated user work.

Architecture

ProjectNamePolicy centralizes POSIX-locale case folding and trimming across validation, lookup, and repair. ProjectNameReconciler groups fetched projects by that key, sorts each duplicate group by UUID, reserves every existing normalized name, and generates collision-safe suffixes.

Triggers

ScenePhaseModifier runs repair at launch/foreground and observes an ID/name fingerprint so delayed CloudKit imports are caught. TransitApp also retries on connectivity restoration. Every path is idempotent.

Trade-off

UUID order is not meaningful to users, but it is stable across peers and avoids a schema migration. Deterministic rename is safer than attempting to merge project metadata and child relationships.

Convergence properties

Given the same record set, all peers compute the same normalized groups, UUID winner ordering, loser suffix bases, and collision fallback sequence. Processing sorted group keys and maintaining a normalized reserved-name set makes the output deterministic even when generated names collide with existing records.

Transaction boundary

The reconciler fetches and mutates on the main context, but exits before mutation when hasChanges is true. A single saveOrRollback() commits the full repair; save failure rolls the context back and later lifecycle/query triggers retry.

Edge cases reviewed

  • Lookup reports ambiguity rather than selecting an arbitrary project before maintenance runs.
  • More than two duplicates receive independent UUID-derived names.
  • Generated-name collisions advance a stable numeric suffix.
  • Self-triggered query observation is harmless because the second pass is idempotent.

Important changes — detailed

ProjectNameReconciler restores a global invariant deterministically

Transit/Transit/Services/ProjectNameReconciler.swift

Why it matters. This is the correctness core: peers must independently choose the same winner and replacement names without deleting user data.

What to look at. ProjectNameReconciler.swift:6-73

Takeaway. Eventually consistent uniqueness needs a deterministic repair policy in addition to local pre-save checks.
Rationale. UUID ordering gives every peer a stable winner without adding a creation timestamp or migrating the CloudKit schema. Deterministic renaming avoids unsafe metadata/relationship merges.

ProjectService shares one locale-stable name policy

Transit/Transit/Services/ProjectService.swift

Why it matters. Lookup, local duplicate validation, and post-sync repair now agree on exactly which names collide across devices with different locales.

What to look at. ProjectService.swift:114-179

Takeaway. Distributed invariants must not depend on each device's current locale.
Rationale. SwiftData predicates cannot express the required folding, and project counts are expected to remain small, so the service fetches and filters in memory.

Lifecycle and query hooks close delayed-import gaps

Transit/Transit/Views/ScenePhaseModifier.swift

Why it matters. CloudKit imports can complete after launch or foreground work, so lifecycle-only maintenance would still leave a timing hole.

What to look at. ScenePhaseModifier.swift:33-55; TransitApp.swift:116,196-201

Takeaway. Observe a stable fingerprint of invariant-relevant fields when synchronization has no explicit completion callback.
Rationale. The existing milestone implementation establishes the same query-observation pattern, while connectivity and scene triggers provide additional retries.

Regression tests model independent-device writes

Transit/TransitTests/ProjectCrossDeviceUniquenessTests.swift

Why it matters. The bug cannot be reproduced through ProjectService because local validation correctly rejects duplicates; separate contexts intentionally bypass that guard like CloudKit import does.

What to look at. ProjectCrossDeviceUniquenessTests.swift:20-136

Takeaway. Cross-context fixtures are a practical local simulation for UUID-distinct records converging from disconnected peers.
Rationale. The tests cover ambiguity before repair, deterministic collision fallback, record/task preservation, idempotence, and deferred retry.

Key decisions

Rename duplicate records instead of merging or deleting them

Project descriptions, repositories, colors, tasks, and milestones cannot be merged without guessing user intent. Name-only repair preserves both records and all child relationships.

Use UUID ordering instead of adding a creation timestamp

Project has no creation timestamp. UUID order is stable across peers and avoids a CloudKit schema migration or ambiguous defaults for existing records.

Defer when the shared context has pending changes

Saving reconciliation mutations on the shared context would also commit unrelated edits. Returning without mutation leaves launch, foreground, connectivity, and query-observer triggers available to retry.

Use invariant locale folding

CloudKit peers may run under different user locales, so name equivalence must be defined independently of device locale. The same policy is used by lookup, validation, and repair.

Review findings

SeverityAreaFindingResolution
minorbugfix report statusThe report header still says Status: Investigating even though the document describes the implemented resolution and marks all verification complete.Non-blocking documentation cleanup: change the status to Fixed before or after merge.
minorrelationship regression coverageThe report and changelog claim both task and milestone relationships are preserved, while the new regression fixture explicitly asserts only task relationships.The implementation only mutates Project.name, so no correctness blocker was found. Consider adding a milestone attached to the duplicate project to make the stated guarantee explicit.
minorname-policy reuseProjectNamePolicy.normalized and most reconciliation mechanics mirror MilestoneNamePolicy and MilestoneNameReconciler.Kept as separate domain policies because winner/scoping rules differ and a generic abstraction would add complexity for only two call sites. Revisit if a third reconciled name domain appears.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d74cfde..63d2258 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +- T-2080: Project names imported from disconnected CloudKit devices are now reconciled deterministically instead of remaining permanently ambiguous. A locale-stable shared policy keeps the lexicographically smallest project UUID's original name and assigns collision-safe UUID suffixes to other records, preserving every project and task/milestone relationship. Launch, foreground, connectivity restoration, and live project query observation trigger or retry maintenance; reconciliation defers while the shared context has unrelated unsaved changes. Cross-context regressions cover ambiguity, deterministic generated-name collisions, relationship preservation, idempotence, and deferred retry behavior.+ - Restored the original width-adaptive dashboard layout: iPhone portrait keeps the multi-column Kanban board whenever at least two 200-point columns fit, and uses segmented single-column navigation only below that boundary. This supersedes T-1802 / PR #205 and corrects requirement 13.1. - Dashboard UI tests now dismiss the iOS search presentation, navigate toolbar overflow actions, match combined detail-row accessibility labels, and disambiguate nested alert buttons so the full iPhone 17 suite covers the restored multi-column portrait layout reliably. 
Transit/Transit/Services/ProjectNameReconciler.swift Added +74 / -0
diff --git a/Transit/Transit/Services/ProjectNameReconciler.swift b/Transit/Transit/Services/ProjectNameReconciler.swiftnew file mode 100644index 0000000..a4112c8--- /dev/null+++ b/Transit/Transit/Services/ProjectNameReconciler.swift@@ -0,0 +1,74 @@+import Foundation+import SwiftData++/// Shared, locale-stable project name semantics. CloudKit peers may use+/// different user locales, so invariant checks and reconciliation must not.+@MainActor+enum ProjectNamePolicy {+    static func normalized(_ name: String) -> String {+        name.trimmingCharacters(in: .whitespacesAndNewlines)+            .folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))+    }++    static func precedesForReconciliation(_ lhs: Project, _ rhs: Project) -> Bool {+        lhs.id.uuidString < rhs.id.uuidString+    }+}++/// Repairs duplicate names imported from other CloudKit devices without+/// deleting projects or changing their task and milestone relationships.+@MainActor+struct ProjectNameReconciler {+    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 or query-observer trigger if another workflow owns changes.+        guard !modelContext.hasChanges else { return 0 }++        let projects = try modelContext.fetch(FetchDescriptor<Project>())+        var groups: [String: [Project]] = [:]+        for project in projects {+            groups[ProjectNamePolicy.normalized(project.name), default: []].append(project)+        }+        var reservedNames = Set(projects.map { ProjectNamePolicy.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: ProjectNamePolicy.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(ProjectNamePolicy.normalized(candidate))+                renamedCount += 1+            }+        }++        if renamedCount > 0 {+            try modelContext.saveOrRollback()+        }+        return renamedCount+    }++    private func uniqueName(+        for duplicate: Project,+        winner: Project,+        reserved: Set<String>+    ) -> String {+        let base = "\(winner.name) (Duplicate \(duplicate.id.uuidString))"+        var candidate = base+        var collisionSuffix = 2+        while reserved.contains(ProjectNamePolicy.normalized(candidate)) {+            candidate = "\(base)-\(collisionSuffix)"+            collisionSuffix += 1+        }+        return candidate+    }+}
Transit/Transit/Services/ProjectService.swift Modified +24 / -10
diff --git a/Transit/Transit/Services/ProjectService.swift b/Transit/Transit/Services/ProjectService.swiftindex 88eb37e..e339436 100644--- a/Transit/Transit/Services/ProjectService.swift+++ b/Transit/Transit/Services/ProjectService.swift@@ -114,10 +114,9 @@ final class ProjectService {         }          if let rawName = name {-            let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines)-            // SwiftData predicates support .localizedStandardContains but not-            // arbitrary case-insensitive equality. Fetch all and filter in memory-            // for exact case-insensitive match (project count is small).+            let normalized = ProjectNamePolicy.normalized(rawName)+            // SwiftData predicates cannot express the locale-stable normalization+            // shared by local validation and cross-device reconciliation.             let descriptor = FetchDescriptor<Project>()             let allProjects: [Project]             do {@@ -126,15 +125,17 @@ final class ProjectService {                 return .failure(.storageFailure(hint: "Failed to fetch projects: \(error)"))             }             let matches = allProjects.filter {-                $0.name.localizedCaseInsensitiveCompare(trimmed) == .orderedSame+                ProjectNamePolicy.normalized($0.name) == normalized             }              switch matches.count {             case 0:+                let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines)                 return .failure(.notFound(hint: "No project named \"\(trimmed)\""))             case 1:                 return .success(matches[0])             default:+                let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines)                 return .failure(.ambiguous(hint: "\(matches.count) projects match \"\(trimmed)\""))             }         }@@ -152,18 +153,31 @@ final class ProjectService {     ///     /// Throws the underlying storage error when the projects cannot be read.     /// CloudKit-backed SwiftData forbids `@Attribute(.unique)`, so this check *is*-    /// the uniqueness invariant — reporting `false` for an unreadable store would-    /// let create/rename commit a duplicate name with nothing underneath to stop-    /// it (T-1614).+    /// the local uniqueness guard. Cross-device conflicts are repaired by+    /// `reconcileDuplicateNames()` after CloudKit imports them.     func projectNameExists(_ name: String, excluding projectId: UUID? = nil) throws -> Bool {-        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)+        let normalized = ProjectNamePolicy.normalized(name)         let allProjects = try fetcher.fetch(FetchDescriptor<Project>())         return allProjects.contains { project in             if let projectId, project.id == projectId { return false }-            return project.name.localizedCaseInsensitiveCompare(trimmed) == .orderedSame+            return ProjectNamePolicy.normalized(project.name) == normalized         }     } +    // MARK: - Post-sync maintenance++    /// Restores global project-name uniqueness after CloudKit imports records+    /// independently created on different devices.+    ///+    /// The lexicographically smallest UUID keeps its original name because+    /// `Project` has no creation timestamp. Every other record receives a+    /// deterministic UUID-derived suffix. Renaming preserves both projects and+    /// all task/milestone relationships. Returns the number of records renamed.+    @discardableResult+    func reconcileDuplicateNames() throws -> Int {+        try ProjectNameReconciler(modelContext: modelContext).reconcile()+    }+     // MARK: - Queries      /// Returns the number of tasks in non-terminal statuses for a project.
Transit/Transit/TransitApp.swift Modified +2 / -0
diff --git a/Transit/Transit/TransitApp.swift b/Transit/Transit/TransitApp.swiftindex 6176c98..883d734 100644--- a/Transit/Transit/TransitApp.swift+++ b/Transit/Transit/TransitApp.swift@@ -113,6 +113,7 @@ struct TransitApp: App {             // connectivity must not start reaching for the CloudKit counter [T-1797].             if cloudSyncActive {                 connectivityMonitor.onRestore = { @Sendable in+                    _ = try? projectService.reconcileDuplicateNames()                     await allocator.promoteProvisionalTasks(in: context)                     await milestoneService.promoteProvisionalMilestones()                 }@@ -194,6 +195,7 @@ struct TransitApp: App {             .environment(\.resolvedTheme, currentTheme.resolved(with: colorScheme))             .modifier(ScenePhaseModifier(                 displayIDAllocator: displayIDAllocator,+                projectService: projectService,                 milestoneService: milestoneService,                 modelContext: container.mainContext             ))
Transit/Transit/Views/ScenePhaseModifier.swift Modified +16 / -1
diff --git a/Transit/Transit/Views/ScenePhaseModifier.swift b/Transit/Transit/Views/ScenePhaseModifier.swiftindex 865954d..b7c7a5e 100644--- a/Transit/Transit/Views/ScenePhaseModifier.swift+++ b/Transit/Transit/Views/ScenePhaseModifier.swift@@ -2,15 +2,23 @@ import SwiftData import SwiftUI  /// Observes scenePhase from within a View context (required by SwiftUI) and-/// triggers display ID promotion on app launch and return to foreground.+/// triggers post-sync maintenance on app launch and return to foreground. struct ScenePhaseModifier: ViewModifier {     @Environment(\.scenePhase) private var scenePhase+    @Query private var projects: [Project]     @Query private var milestones: [Milestone]      let displayIDAllocator: DisplayIDAllocator+    let projectService: ProjectService     let milestoneService: MilestoneService     let modelContext: ModelContext +    private var projectNameFingerprint: String {+        projects.map { "\($0.id.uuidString)|\($0.name)" }+            .sorted()+            .joined(separator: "\u{1F}")+    }+     private var milestoneNameFingerprint: String {         milestones.map {             "\($0.id.uuidString)|\($0.project?.id.uuidString ?? "orphan")|\($0.name)"@@ -22,17 +30,24 @@ struct ScenePhaseModifier: ViewModifier {     func body(content: Content) -> some View {         content             .task {+                _ = try? projectService.reconcileDuplicateNames()                 await displayIDAllocator.promoteProvisionalTasks(in: modelContext)                 await milestoneService.promoteProvisionalMilestones()             }             .onChange(of: scenePhase) { _, newPhase in                 if newPhase == .active {                     Task {+                        _ = try? projectService.reconcileDuplicateNames()                         await displayIDAllocator.promoteProvisionalTasks(in: modelContext)                         await milestoneService.promoteProvisionalMilestones()                     }                 }             }+            .onChange(of: projectNameFingerprint) {+                // CloudKit imports may finish after lifecycle hooks. Query observation+                // supplies a post-sync pass; deferred runs retry on later triggers.+                _ = try? projectService.reconcileDuplicateNames()+            }             .onChange(of: milestoneNameFingerprint) {                 // CloudKit imports may complete after launch/foreground hooks. Query                 // observation provides the post-sync pass; reconciliation is idempotent.
Transit/TransitTests/ProjectCrossDeviceUniquenessTests.swift Added +137 / -0
diff --git a/Transit/TransitTests/ProjectCrossDeviceUniquenessTests.swift b/Transit/TransitTests/ProjectCrossDeviceUniquenessTests.swiftnew file mode 100644index 0000000..ccd85c9--- /dev/null+++ b/Transit/TransitTests/ProjectCrossDeviceUniquenessTests.swift@@ -0,0 +1,137 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// Regression coverage for T-2080. CloudKit identifies projects by UUID, so two+/// disconnected devices can each create the same normalized name and later sync+/// both records into one store. These tests simulate that import with independent+/// contexts sharing one container and intentionally bypass `createProject`.+@MainActor @Suite(.serialized)+struct ProjectCrossDeviceUniquenessTests {++    private struct Environment {+        let testContainer: TestModelContainer+        let winnerID: UUID+        let duplicateID: UUID+        let reservedCollisionID: UUID+    }++    private func makeSyncedDuplicateEnvironment() throws -> Environment {+        let testContainer = try TestModelContainer()+        let container = testContainer.container+        let winnerID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001"))+        let duplicateID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002"))+        let reservedCollisionID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000003"))++        let firstDevice = ModelContext(container)+        let winner = Project(+            name: "Transit",+            description: "Created on device A",+            gitRepo: nil,+            colorHex: "#FF0000"+        )+        winner.id = winnerID+        firstDevice.insert(winner)+        let firstTask = TransitTask(+            name: "Device A task", type: .feature, project: winner, displayID: .permanent(10)+        )+        firstDevice.insert(firstTask)+        try firstDevice.save()++        let secondDevice = ModelContext(container)+        let duplicate = Project(+            name: "transit",+            description: "Created on device B",+            gitRepo: nil,+            colorHex: "#00FF00"+        )+        duplicate.id = duplicateID+        secondDevice.insert(duplicate)+        let secondTask = TransitTask(+            name: "Device B task", type: .bug, project: duplicate, displayID: .permanent(11)+        )+        secondDevice.insert(secondTask)++        // Force the reconciler to avoid a pre-existing generated-name collision.+        let reservedCollision = Project(+            name: "Transit (Duplicate \(duplicateID.uuidString))",+            description: "Existing project",+            gitRepo: nil,+            colorHex: "#0000FF"+        )+        reservedCollision.id = reservedCollisionID+        secondDevice.insert(reservedCollision)+        try secondDevice.save()++        return Environment(+            testContainer: testContainer,+            winnerID: winnerID,+            duplicateID: duplicateID,+            reservedCollisionID: reservedCollisionID+        )+    }++    @Test func nameLookupDoesNotChooseAnArbitrarySyncedDuplicate() throws {+        let environment = try makeSyncedDuplicateEnvironment()+        let receivingDevice = ModelContext(environment.testContainer.container)+        let service = ProjectService(modelContext: receivingDevice)++        let result = service.findProject(name: "TRANSIT")++        guard case .failure(.ambiguous) = result else {+            Issue.record("Expected synced duplicates to produce an ambiguous lookup")+            return+        }+    }++    @Test func postSyncMaintenanceReconcilesNamesWithoutDeletingProjectsOrTasks() throws {+        let environment = try makeSyncedDuplicateEnvironment()+        let receivingDevice = ModelContext(environment.testContainer.container)+        let service = ProjectService(modelContext: receivingDevice)++        #expect(try service.reconcileDuplicateNames() == 1)++        let projects = try receivingDevice.fetch(FetchDescriptor<Project>())+        #expect(projects.count == 3, "Reconciliation must preserve every synced project")+        #expect(Set(projects.map(\.id)) == [+            environment.winnerID,+            environment.duplicateID,+            environment.reservedCollisionID+        ])+        #expect(Set(projects.map { $0.name.lowercased() }).count == 3)+        #expect(projects.first { $0.id == environment.winnerID }?.name == "Transit")+        #expect(+            projects.first { $0.id == environment.duplicateID }?.name+                == "Transit (Duplicate \(environment.duplicateID.uuidString))-2",+            "The UUID winner and generated collision fallback must be deterministic"+        )++        let tasks = try receivingDevice.fetch(FetchDescriptor<TransitTask>())+        #expect(tasks.count == 2)+        #expect(Set(tasks.compactMap { $0.project?.id }) == [+            environment.winnerID,+            environment.duplicateID+        ], "Reconciliation must preserve task-to-project relationships")+        #expect(try service.reconcileDuplicateNames() == 0, "Reconciliation must be idempotent")+    }++    @Test func reconciliationDefersForUnsavedChangesAndCanRetry() throws {+        let environment = try makeSyncedDuplicateEnvironment()+        let receivingDevice = ModelContext(environment.testContainer.container)+        let service = ProjectService(modelContext: receivingDevice)+        let unrelated = Project(+            name: "Unsaved local edit",+            description: "Must not be committed by maintenance",+            gitRepo: nil,+            colorHex: "#FFFFFF"+        )+        receivingDevice.insert(unrelated)++        #expect(try service.reconcileDuplicateNames() == 0)+        #expect(receivingDevice.hasChanges)++        try receivingDevice.save()+        #expect(try service.reconcileDuplicateNames() == 1)+    }+}
docs/agent-notes/project-structure.md Modified +1 / -1
diff --git a/docs/agent-notes/project-structure.md b/docs/agent-notes/project-structure.mdindex 8d4bba3..3228e92 100644--- a/docs/agent-notes/project-structure.md+++ b/docs/agent-notes/project-structure.md@@ -34,7 +34,7 @@ Transit/Transit/ - **StatusEngine** (`Services/StatusEngine.swift`) — Pure logic for task status transitions. All status changes go through `initializeNewTask` or `applyTransition`. Manages `completionDate` and `lastStatusChangeDate` side effects. - **DisplayIDAllocator** (`Services/DisplayIDAllocator.swift`) — CloudKit-backed counter for sequential display IDs (T-1, T-2...). Uses optimistic locking with `CKModifyRecordsOperation`. Falls back to provisional IDs when offline. Marked `@unchecked Sendable` since it does async CloudKit work off MainActor. - **TaskService** (`Services/TaskService.swift`) — Coordinates task creation, status changes, and lookups. Uses StatusEngine for transitions and DisplayIDAllocator for IDs. `@MainActor @Observable`.-- **ProjectService** (`Services/ProjectService.swift`) — Project creation, lookup (by ID or case-insensitive name), and active task counting. Returns `Result<Project, ProjectLookupError>` for find operations. `@MainActor @Observable`.+- **ProjectService** (`Services/ProjectService.swift`) — Project creation, lookup (by ID or locale-stable normalized name), active task counting, and deterministic post-sync duplicate-name reconciliation. `ProjectNameReconciler` keeps the lexicographically smallest UUID's name and renames other records without changing task/milestone relationships. Returns `Result<Project, ProjectLookupError>` for find operations. `@MainActor @Observable`. - **ConnectivityMonitor** (`Services/ConnectivityMonitor.swift`) — NWPathMonitor wrapper that tracks connectivity state and fires `onRestore` callback when connectivity transitions from unsatisfied to satisfied. `@Observable @unchecked Sendable` since NWPathMonitor callbacks come from a background queue. - **SyncManager** (`Services/SyncManager.swift`) — Manages CloudKit sync enabled/disabled preference via UserDefaults (key: `syncEnabled`, shared with SettingsView's `@AppStorage`). Provides `makeModelConfiguration(schema:)` factory that creates the appropriate `ModelConfiguration` based on the sync preference. Sync toggle takes effect on next app launch since `ModelContainer` must be recreated. `@Observable`. - **ContainerFactory** (`Services/ContainerFactory.swift`) — Creates `ModelContainer` with graceful fallback. Returns `ContainerOutcome` containing the container and any error. On primary init failure, falls back to in-memory container and logs via OSLog. Used by `TransitApp.init()` to avoid crashing on corrupted stores or CloudKit misconfiguration (T-504).
specs/bugfixes/cross-device-project-name-uniqueness/report.md Added +111 / -0
diff --git a/specs/bugfixes/cross-device-project-name-uniqueness/report.md b/specs/bugfixes/cross-device-project-name-uniqueness/report.mdnew file mode 100644index 0000000..9488491--- /dev/null+++ b/specs/bugfixes/cross-device-project-name-uniqueness/report.md@@ -0,0 +1,111 @@+# Bugfix Report: Cross-Device Project Name Uniqueness++**Date:** 2026-08-30+**Status:** Investigating+**Ticket:** T-2080++## Description of the Issue++Project names are intended to be case-insensitively unique. Local creation and rename checks enforce that rule only against records already present in one device's store. Two disconnected devices can therefore create UUID-distinct projects with the same name, and CloudKit later preserves both records. Name-addressed App Intent and MCP operations then fail with `AMBIGUOUS_PROJECT`, and the duplicate remains visible indefinitely.++**Reproduction steps:**+1. Create project `Transit` on device A while device B is disconnected from that change.+2. Create project `transit` on device B.+3. Allow both UUID-backed records to sync.+4. Observe that both projects remain and name-based automation cannot resolve `Transit`.++**Impact:** Cross-device project creation or rename can permanently break every automation path that addresses a project by name.++## Investigation Summary++The investigation followed the systematic-debugging workflow.++- **Symptoms examined:** UUID-distinct, case-insensitively equal project names after sync; fail-closed ambiguous lookup; no later repair.+- **Code inspected:** `ProjectService` creation, update, lookup, and uniqueness checks; `Project`; `MilestoneNameReconciler`; `MilestoneService` maintenance; `ScenePhaseModifier`; connectivity restoration wiring in `TransitApp`; T-1938 tests and decision record.+- **Hypotheses tested:**+  - Local pre-save checks could prevent the state. Ruled out because disconnected stores cannot see each other's records.+  - CloudKit would reject one write. Ruled out because records have distinct UUID identities and SwiftData cannot use a CloudKit-backed unique attribute.+  - Existing lifecycle maintenance would repair projects. Ruled out because it invokes milestone reconciliation only.++## Discovered Root Cause++The project-name invariant is enforced as a local service-layer precondition even though the store is multi-writer and eventually consistent. Unlike milestones, projects have no deterministic post-sync reconciliation or project query observation.++**Defect type:** Distributed race / missing post-sync reconciliation.++**Five Whys:**+1. Why does project lookup become ambiguous? Because several projects have the same normalized name.+2. Why can several projects pass creation validation? Because disconnected devices each inspect only their local store.+3. Why does CloudKit retain both? Because each project has a different UUID record identity.+4. Why is there no database constraint? Because CloudKit-backed SwiftData does not support `@Attribute(.unique)`.+5. Why does the invalid state persist? Because project lifecycle and query-observer reconciliation was never added when the milestone equivalent was implemented.++**Contributing factors:** `Project` has no creation timestamp, so deterministic winner selection must use UUID ordering rather than the milestone policy's creation-date ordering. Existing lookup already fails closed, preventing wrong-record mutation but not restoring usability.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Services/ProjectNameReconciler.swift` adds a locale-stable name policy and deterministic, data-preserving duplicate repair. The lexicographically smallest UUID keeps the original name; other projects receive UUID-derived suffixes with collision fallback.+- `Transit/Transit/Services/ProjectService.swift` uses the shared normalization for local validation and lookup, and exposes `reconcileDuplicateNames()` for maintenance triggers.+- `Transit/Transit/Views/ScenePhaseModifier.swift` reconciles on launch/foreground and observes a project ID/name fingerprint so delayed CloudKit imports trigger a post-sync pass.+- `Transit/Transit/TransitApp.swift` retries reconciliation when connectivity returns.+- Reconciliation defers whenever the shared context has unsaved changes, preventing it from committing unrelated edits; later lifecycle, connectivity, or query-observer triggers retry it.++**Approach rationale:** Deterministic renaming restores name-based automation without deleting projects or guessing how to merge descriptions, repository metadata, colors, tasks, or milestones. UUID order is stable on every peer and requires no model/schema migration.++**Alternatives considered:**+- **Direct CloudKit reservation keyed by normalized name** — rejected because offline creation cannot synchronously claim a reservation, it would add a second direct-CloudKit subsystem, and existing/offline conflicts would still require reconciliation.+- **Merge and delete duplicate projects** — rejected because project metadata and child relationships cannot be merged without risking data loss.+- **Add a project creation timestamp** — rejected because UUID provides a sufficient deterministic order without a CloudKit schema migration or ambiguous defaults for existing records.+- **Ambiguity reporting only** — already present, but it leaves the invalid state and broken automation unresolved.++## Regression Test++**Test file:** `Transit/TransitTests/ProjectCrossDeviceUniquenessTests.swift`++**Test names:**+- `nameLookupDoesNotChooseAnArbitrarySyncedDuplicate`+- `postSyncMaintenanceReconcilesNamesWithoutDeletingProjectsOrTasks`+- `reconciliationDefersForUnsavedChangesAndCanRetry`++**What it verifies:** Cross-context duplicate imports remain ambiguous before repair; reconciliation preserves every project and task relationship, chooses a UUID-deterministic winner, avoids generated-name collisions, is idempotent, and defers rather than saving unrelated changes.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/ProjectNameReconciler.swift` | Deterministic project-name policy and reconciliation |+| `Transit/Transit/Services/ProjectService.swift` | Shared normalization and maintenance entry point |+| `Transit/Transit/Views/ScenePhaseModifier.swift` | Launch, foreground, and project query-observer triggers |+| `Transit/Transit/TransitApp.swift` | Connectivity-restoration retry and service wiring |+| `Transit/TransitTests/ProjectCrossDeviceUniquenessTests.swift` | Cross-context ambiguity, preservation, collision, idempotence, and retry regressions |+| `docs/agent-notes/project-structure.md` | Document project reconciliation lifecycle |+| `CHANGELOG.md` | Record the T-2080 fix |+| `specs/bugfixes/cross-device-project-name-uniqueness/report.md` | Investigation and resolution record |++## Verification++**Automated:**+- [x] Regression test fails before the fix (`make test-quick`: missing `ProjectService.reconcileDuplicateNames` at four call sites)+- [x] macOS unit suite passes (`make test-quick`)+- [x] Full iOS simulator suite passes (`make test`)+- [x] UI suite passes (`make test-ui`)+- [x] Linters/validators pass (`make lint`)++**Manual verification:**+- Inspected `ScenePhaseModifier` and `TransitApp` wiring to confirm launch, foreground, connectivity restoration, and delayed project ID/name query changes all invoke reconciliation.+- Confirmed maintenance exits without saving when the shared context has pending changes, leaving later triggers available to retry.++## Prevention++- Treat local uniqueness checks over CloudKit data as best-effort guards, not global constraints.+- Pair fail-closed name lookup with deterministic, data-preserving reconciliation for eventually consistent records.+- Keep local validation, lookup, and reconciliation on one locale-stable normalization policy.++## Related++- T-76 — local project duplicate prevention.+- T-1938 — analogous cross-device milestone-name reconciliation.+- `specs/bugfixes/cross-device-milestone-name-uniqueness/report.md`.

Things to double-check

Real CloudKit import timing

Automated tests use independent SwiftData contexts rather than a live CloudKit container. Before release, spot-check two physical devices creating case variants offline, then reconnect and confirm both peers converge to the same generated name.

User-visible generated names

Generated names intentionally include a full UUID for deterministic convergence. Confirm that this explicit but long suffix is acceptable in project menus and cards.