transit branch T-2103 bugfix commits 3 files 11 touched lines +436 / -36

Pre-push review: T-2103 project-deletion creation race

Review of the three commits in origin/main...HEAD for PR #246. The branch closes the post-allocation project-deletion race for task and milestone creation while preserving automation error contracts.

At a glance

  • Correctness: task and milestone creation now reject a project deleted while display-ID allocation is suspended.
  • State reconciliation: pending inserts/deletes are read from the live context while peer-committed deletion is checked through a fresh context.
  • Contracts: service, App Intent, and MCP paths retain deterministic project-not-found errors.
  • Verification: deterministic two-context regressions pass; make test, make test-ui, and make lint completed successfully.

Verdict

Ready to push

No correctness, quality, reuse, efficiency, specification, documentation, or testing blockers were found. The validator is placed after the final suspension point and before synchronous insertion, uses both live pending state and a fresh committed-state probe, and preserves App Intent and MCP project-not-found behavior. Full iOS tests, UI tests, and lint all passed during this review.

Commits

Three-level explanation

What changed

Transit pauses while obtaining a display ID for a new task or milestone. Before this branch, the chosen project could disappear during that pause and creation could still continue with an old in-memory reference. Transit now checks the project again immediately before creating the dependent record.

Why it matters

Callers no longer receive false success or leave a task or milestone behind after its required project has been deleted.

Completeness

Both creation paths, public error mappings, regression coverage, and documentation are complete.

Architecture

CreationProjectValidator centralizes a service-layer invariant shared by TaskService.createTask and MilestoneService.createMilestone. It runs after display-ID allocation and cancellation handling, with no later suspension before insertion.

Pattern

The live ModelContext detects pending inserts and deletes. A transient context against the same container verifies committed existence and bypasses a stale registered object. Fetch errors propagate rather than being converted into not-found.

Trade-off

Creation incurs one bounded UUID fetch in a fresh context, which is justified by closing the stale-object race without rejecting valid unsaved projects.

Deep dive

The defect is a TOCTOU window across DisplayIDAllocator.allocateNextID. MainActor isolation permits reentrancy at that await. The post-await validator first rejects a matching pending deletion, requires live-context visibility, accepts a matching pending insert, and otherwise requires a fresh committed-state match.

Boundary guarantees

The remaining aggregate checks and insertOrDelete path are synchronous on MainActor, so the known suspension window is closed. SwiftData and the CloudKit display-ID counter do not provide one transaction spanning both systems, so a truly concurrent store-level deletion after the final probe remains an underlying platform limitation rather than a regression in this change.

Completeness assessment

Fully implemented: task and milestone guards, domain errors, App Intent and MCP mappings, deterministic service/MCP regressions, changelog, and implementation report. Partially implemented or missing: none for T-2103.

Important changes — detailed

CreationProjectValidator reconciles live and committed project state

Transit/Transit/Services/CreationProjectValidator.swift

Why it matters. This is the central correctness fix: it rejects local pending deletion and peer-committed deletion without rejecting a valid pending insert.

What to look at. CreationProjectValidator.validate / exists, lines 10-47

Takeaway. For SwiftData race checks, combine the live context's pending state with a fresh context's committed view; neither source is sufficient alone.
Rationale. A live fetch can retain a stale registered object after peer deletion, while a fresh-only fetch cannot see a valid unsaved insert.

Creation services enforce project liveness after allocation

Transit/Transit/Services/TaskService.swift

Why it matters. Placing the guard after the allocator await closes the exact TOCTOU window before task or milestone construction and insertion.

What to look at. TaskService.createTask line 129; MilestoneService.createMilestone line 83

Takeaway. Revalidate expiring model invariants after the last suspension point, not only at adapter entry.
Rationale. A pre-allocation lookup cannot guarantee that the project survives asynchronous display-ID allocation.

Automation adapters preserve project-not-found contracts

Transit/Transit/MCP/MCPToolHandler.swift

Why it matters. The new service failure remains actionable and stable for both App Intent and MCP clients instead of falling into generic creation-failed responses.

What to look at. MCP task catch lines 446-447; milestone catch lines 779-780; IntentHelpers.mapMilestoneError lines 140-141

Takeaway. When adding a domain error, audit every boundary mapping so internal correctness fixes do not accidentally change public protocols.
Rationale. Existing clients already understand PROJECT_NOT_FOUND and the MCP text “No matching project found.”

Deterministic two-context tests reproduce the race

Transit/TransitTests/CreationProjectDeletionTests.swift

Why it matters. The tests suspend allocation, delete through a peer context, resume, then assert exact errors and absence of both pending and committed dependents.

What to look at. CreationProjectDeletionTests, lines 53-163

Takeaway. An injectable allocation gate turns a timing-sensitive async persistence race into a deterministic regression test.
Rationale. A normal concurrent test would be flaky and might never exercise the post-validation/pre-insertion window.

Key decisions

Use a shared creation-specific project validator

Task and milestone creation share the same expiring project-liveness invariant, so one helper prevents subtly divergent race handling.

Preserve pending inserts while checking committed storage

The validator accepts a matching project in insertedModelsArray after live visibility is confirmed; persisted projects additionally require a fresh-context match.

Validate after cancellation and before remaining synchronous work

Cancellation remains the first post-allocation decision, then project liveness is checked before milestone/name validation and insertion. No additional await reopens the known race.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d74cfde..a3ca7c6 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -36,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  - T-1803: `UpdateStatusIntent` now includes a missing requested `displayId` in its `TASK_NOT_FOUND` hint (`No task with displayId N`), while missing UUIDs retain the existing generic lookup hint and malformed identifiers, duplicate IDs, status validation, and atomic mutation behavior remain unchanged. ### Fixed+- T-2103: Task and milestone creation now revalidate the selected project after asynchronous display-ID allocation using live pending state plus a fresh committed-state probe. A peer deletion now returns the established project-not-found error before insertion instead of persisting an orphan task or reporting false milestone success; App Intent and MCP callers retain their deterministic project-not-found contracts, with two-context service and MCP regressions covering the race. - T-1628: `make test-quick` and every relevant source-building Makefile target now keep Clang module caches and SwiftPM manifest modules/diagnostics workspace-local. `CLANG_MODULE_CACHE_PATH` is passed as an Xcode build setting, `SWIFTPM_MODULECACHE_OVERRIDE` is exported for manifest compilation, and the cache guard now asserts these supported controls while explicitly preserving the intentional omission of `-clonedSourcePackagesDirPath` and `-packageCachePath` so package resolution continues to work. Xcode recipes also explicitly enable `pipefail`, preventing `xcbeautify` from masking build failures on macOS's bundled GNU Make 3.81. - T-1613: MCP `query_tasks` now returns the exact tool error `Failed to fetch comments: <error>` when comment serialization cannot read storage, rather than reporting a successful task with `comments: []`. The detailed display-ID and task-list paths share this throwing serialization boundary; genuine empty comment collections retain their successful `comments: []` response. `update_task_status` continues serializing the `Comment` returned by its atomic mutation directly (T-1823), so it performs no post-commit comment fetch that could prompt a retry or duplicate a persisted comment. Deterministic MCP regressions cover both query errors, legitimate empty comments, and zero status-response fetches. - T-1620: `GenerateReportIntent` now returns the established `INTERNAL_ERROR` JSON envelope with a source-specific stable hint when terminal task or milestone fetches fail, instead of false empty-report Markdown. Successful empty reports retain their existing Markdown and date-range formatting; deterministic regressions cover both failures and the valid-empty response.
Transit/Transit/Intents/IntentHelpers.swift Modified +2 / -0
diff --git a/Transit/Transit/Intents/IntentHelpers.swift b/Transit/Transit/Intents/IntentHelpers.swiftindex 55ba209..20651fa 100644--- a/Transit/Transit/Intents/IntentHelpers.swift+++ b/Transit/Transit/Intents/IntentHelpers.swift@@ -137,6 +137,8 @@ nonisolated enum IntentHelpers {             .invalidInput(hint: "Milestone name cannot be empty")         case .milestoneNotFound:             .milestoneNotFound(hint: "No matching milestone found")+        case .projectNotFound:+            .projectNotFound(hint: "No matching project found")         case .duplicateName:             .duplicateMilestoneName(hint: "A milestone with this name already exists in the project")         case .ambiguousName:
Transit/Transit/MCP/MCPToolHandler.swift Modified +4 / -0
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex c183bfd..c94fcdc 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -443,6 +443,8 @@ final class MCPToolHandler {             )         } catch TaskService.Error.milestoneNotOpen {             return errorResult("The selected milestone is no longer open")+        } catch TaskService.Error.projectNotFound {+            return errorResult("No matching project found")         } catch {             return errorResult("Task creation failed: \(error)")         }@@ -774,6 +776,8 @@ extension MCPToolHandler {             return errorResult("A milestone with this name already exists in the project")         } catch MilestoneService.Error.invalidName {             return errorResult("Milestone name cannot be empty")+        } catch MilestoneService.Error.projectNotFound {+            return errorResult("No matching project found")         } catch {             return errorResult("Milestone creation failed: \(error)")         }
Transit/Transit/Services/CreationProjectValidator.swift Added +48 / -0
diff --git a/Transit/Transit/Services/CreationProjectValidator.swift b/Transit/Transit/Services/CreationProjectValidator.swiftnew file mode 100644index 0000000..466c9ee--- /dev/null+++ b/Transit/Transit/Services/CreationProjectValidator.swift@@ -0,0 +1,48 @@+import Foundation+import SwiftData++/// Validates that a project remains usable at a creation persistence boundary.+///+/// The live context preserves pending inserts and deletes, while a transient+/// context observes peer-committed deletion even when the live context still+/// has a stale registered object. Fetch failures propagate so creation fails+/// closed rather than treating unreadable storage as a missing project.+enum CreationProjectValidator {+    static func validate<E: Error>(+        _ project: Project,+        in modelContext: ModelContext,+        error: @autoclosure () -> E+    ) throws {+        guard try exists(project, in: modelContext) else {+            throw error()+        }+    }++    private static func exists(+        _ project: Project,+        in modelContext: ModelContext+    ) throws -> Bool {+        let projectID = project.id++        if modelContext.deletedModelsArray.contains(where: {+            ($0 as? Project)?.id == projectID+        }) {+            return false+        }++        let descriptor = FetchDescriptor<Project>(+            predicate: #Predicate { $0.id == projectID }+        )+        guard try modelContext.fetch(descriptor).first != nil else {+            return false+        }++        if modelContext.insertedModelsArray.contains(where: {+            ($0 as? Project)?.id == projectID+        }) {+            return true+        }++        return try ModelContext(modelContext.container).fetch(descriptor).first != nil+    }+}
Transit/Transit/Services/MilestoneService+Error.swift Added +38 / -0
diff --git a/Transit/Transit/Services/MilestoneService+Error.swift b/Transit/Transit/Services/MilestoneService+Error.swiftnew file mode 100644index 0000000..063bef0--- /dev/null+++ b/Transit/Transit/Services/MilestoneService+Error.swift@@ -0,0 +1,38 @@+import Foundation++// MARK: - Errors++extension MilestoneService {++    enum Error: Swift.Error, Equatable, LocalizedError {+        case invalidName+        case milestoneNotFound+        case projectNotFound+        case duplicateName+        case ambiguousName+        case duplicateDisplayID+        case projectRequired+        case projectMismatch++        var errorDescription: String? {+            switch self {+            case .invalidName:+                "Milestone name cannot be empty."+            case .milestoneNotFound:+                "The specified milestone could not be found."+            case .projectNotFound:+                "The selected project 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:+                "Task must belong to a project before assigning a milestone."+            case .projectMismatch:+                "Milestone and task must belong to the same project."+            }+        }+    }+}
Transit/Transit/Services/MilestoneService.swift Modified +1 / -34
diff --git a/Transit/Transit/Services/MilestoneService.swift b/Transit/Transit/Services/MilestoneService.swiftindex 282d4a9..439b1f7 100644--- a/Transit/Transit/Services/MilestoneService.swift+++ b/Transit/Transit/Services/MilestoneService.swift@@ -80,6 +80,7 @@ final class MilestoneService {         // so a successfully allocated ID cannot turn a cancelled operation into a         // persisted milestone (T-1765).         try Task.checkCancellation()+        try CreationProjectValidator.validate(project, in: modelContext, error: Error.projectNotFound)          // Re-check uniqueness after the allocation await. The check above ran         // before this method suspended, so a concurrent create could have@@ -364,37 +365,3 @@ final class MilestoneService {     }  }--// MARK: - Errors--extension MilestoneService {--    enum Error: Swift.Error, Equatable, LocalizedError {-        case invalidName-        case milestoneNotFound-        case duplicateName-        case ambiguousName-        case duplicateDisplayID-        case projectRequired-        case projectMismatch--        var errorDescription: String? {-            switch self {-            case .invalidName:-                "Milestone name cannot be empty."-            case .milestoneNotFound:-                "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:-                "Task must belong to a project before assigning a milestone."-            case .projectMismatch:-                "Milestone and task must belong to the same project."-            }-        }-    }-}
Transit/Transit/Services/TaskService.swift Modified +1 / -0
diff --git a/Transit/Transit/Services/TaskService.swift b/Transit/Transit/Services/TaskService.swiftindex 6302e76..7df651e 100644--- a/Transit/Transit/Services/TaskService.swift+++ b/Transit/Transit/Services/TaskService.swift@@ -126,6 +126,7 @@ final class TaskService {         // and inserting the model so a successfully allocated ID cannot turn a         // cancelled operation into a persisted task (T-1765).         try Task.checkCancellation()+        try CreationProjectValidator.validate(project, in: modelContext, error: Error.projectNotFound)         try TaskCreationMilestoneValidator.validate(             milestone,             projectID: project.id,
Transit/TransitTests/CreationProjectDeletionTests.swift Added +164 / -0
diff --git a/Transit/TransitTests/CreationProjectDeletionTests.swift b/Transit/TransitTests/CreationProjectDeletionTests.swiftnew file mode 100644index 0000000..2daf0f0--- /dev/null+++ b/Transit/TransitTests/CreationProjectDeletionTests.swift@@ -0,0 +1,164 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// T-2103 regressions for project deletion while creation is suspended in+/// display-ID allocation. An independent context models a peer or CloudKit merge+/// deleting the project after validation but before the create resumes.+@MainActor @Suite(.serialized)+struct CreationProjectDeletionTests {++    private func makeProject(in context: ModelContext) throws -> Project {+        let project = Project(+            name: "Deleted During Creation",+            description: "",+            gitRepo: nil,+            colorHex: "#000000"+        )+        context.insert(project)+        try context.save()+        return project+    }++    private func deleteProject(+        id: UUID,+        from container: ModelContainer+    ) throws {+        let peerContext = ModelContext(container)+        let descriptor = FetchDescriptor<Project>(predicate: #Predicate { $0.id == id })+        let peerProject = try #require(try peerContext.fetch(descriptor).first)+        peerContext.delete(peerProject)+        try peerContext.save()+    }++    private func expectNoProjectsOrTasks(in container: ModelContainer) throws {+        let probe = ModelContext(container)+        #expect(try probe.fetch(FetchDescriptor<Project>()).isEmpty)+        #expect(+            try probe.fetch(FetchDescriptor<TransitTask>()).isEmpty,+            "Task creation must not commit an orphan after its project is deleted"+        )+    }++    private func expectNoProjectsOrMilestones(in container: ModelContainer) throws {+        let probe = ModelContext(container)+        #expect(try probe.fetch(FetchDescriptor<Project>()).isEmpty)+        #expect(+            try probe.fetch(FetchDescriptor<Milestone>()).isEmpty,+            "Milestone creation must not commit a record after its project is deleted"+        )+    }++    @Test func taskCreationRejectsProjectDeletedDuringAllocation() async throws {+        let testContainer = try TestModelContainer()+        let context = testContainer.context+        let store = AllocationGatedCounterStore(initialNextDisplayID: 100)+        let allocator = DisplayIDAllocator(store: store)+        let service = TaskService(modelContext: context, displayIDAllocator: allocator)+        let project = try makeProject(in: context)+        let projectID = project.id++        let creation = Task { @MainActor in+            _ = try await service.createTask(+                name: "Must Not Persist",+                description: nil,+                type: .bug,+                project: project+            )+        }++        let allocationStarted = await store.waitUntilAllocationStarts()+        #expect(allocationStarted, "Task creation never reached the allocation suspension")+        guard allocationStarted else {+            creation.cancel()+            await store.releaseAllocation()+            _ = try? await creation.value+            return+        }++        try deleteProject(id: projectID, from: testContainer.container)+        await store.releaseAllocation()++        await #expect(throws: TaskService.Error.projectNotFound) {+            try await creation.value+        }++        #expect(try context.fetch(FetchDescriptor<TransitTask>()).isEmpty)+        try expectNoProjectsOrTasks(in: testContainer.container)+    }++#if os(macOS)+    @Test func mcpTaskCreationReportsProjectDeletionWithEstablishedError() async throws {+        let store = AllocationGatedCounterStore(initialNextDisplayID: 300)+        let env = try MCPTestHelpers.makeEnv(taskCounterStore: store)+        let project = try makeProject(in: env.context)+        let projectID = project.id++        let creation = Task { @MainActor in+            await env.handler.handle(MCPTestHelpers.toolCallRequest(+                tool: "create_task",+                arguments: [+                    "name": "Must Not Persist",+                    "type": "bug",+                    "projectId": projectID.uuidString+                ]+            ))+        }++        let allocationStarted = await store.waitUntilAllocationStarts()+        #expect(allocationStarted, "MCP task creation never reached the allocation suspension")+        guard allocationStarted else {+            creation.cancel()+            await store.releaseAllocation()+            _ = await creation.value+            return+        }++        try deleteProject(id: projectID, from: env.context.container)+        await store.releaseAllocation()++        let response = await creation.value+        #expect(try MCPTestHelpers.isError(response))+        #expect(try MCPTestHelpers.errorText(response) == "No matching project found")+        try expectNoProjectsOrTasks(in: env.context.container)+    }+#endif++    @Test func milestoneCreationRejectsProjectDeletedDuringAllocation() async throws {+        let testContainer = try TestModelContainer()+        let context = testContainer.context+        let store = AllocationGatedCounterStore(initialNextDisplayID: 200)+        let allocator = DisplayIDAllocator(store: store)+        let service = MilestoneService(modelContext: context, displayIDAllocator: allocator)+        let project = try makeProject(in: context)+        let projectID = project.id++        let creation = Task { @MainActor in+            _ = try await service.createMilestone(+                name: "Must Not Persist",+                description: nil,+                project: project+            )+        }++        let allocationStarted = await store.waitUntilAllocationStarts()+        #expect(allocationStarted, "Milestone creation never reached the allocation suspension")+        guard allocationStarted else {+            creation.cancel()+            await store.releaseAllocation()+            _ = try? await creation.value+            return+        }++        try deleteProject(id: projectID, from: testContainer.container)+        await store.releaseAllocation()++        await #expect(throws: MilestoneService.Error.projectNotFound) {+            try await creation.value+        }++        #expect(try context.fetch(FetchDescriptor<Milestone>()).isEmpty)+        try expectNoProjectsOrMilestones(in: testContainer.container)+    }+}
Transit/TransitTests/MCPTestHelpers.swift Modified +3 / -2
diff --git a/Transit/TransitTests/MCPTestHelpers.swift b/Transit/TransitTests/MCPTestHelpers.swiftindex 66d32f0..1e78e28 100644--- a/Transit/TransitTests/MCPTestHelpers.swift+++ b/Transit/TransitTests/MCPTestHelpers.swift@@ -26,11 +26,12 @@ enum MCPTestHelpers {         commentFetcher: (any CommentFetching)? = nil,         milestoneFetcher: (any MilestoneFetching)? = nil,         milestoneDisplayIDFinder: (any MilestoneDisplayIDFinding)? = nil,-        milestoneServiceFetcher: (any ModelFetching)? = nil+        milestoneServiceFetcher: (any ModelFetching)? = nil,+        taskCounterStore: (any DisplayIDAllocator.CounterStore)? = nil     ) throws -> MCPTestEnv {         let testContainer = try TestModelContainer()         let context = testContainer.context-        let taskStore = InMemoryCounterStore()+        let taskStore: any DisplayIDAllocator.CounterStore = taskCounterStore ?? InMemoryCounterStore()         let taskAllocator = DisplayIDAllocator(store: taskStore)         let taskService = TaskService(             modelContext: context,
specs/bugfixes/creation-after-project-deletion/implementation.md Added +57 / -0
diff --git a/specs/bugfixes/creation-after-project-deletion/implementation.md b/specs/bugfixes/creation-after-project-deletion/implementation.mdnew file mode 100644index 0000000..77913c6--- /dev/null+++ b/specs/bugfixes/creation-after-project-deletion/implementation.md@@ -0,0 +1,57 @@+# T-2103 Implementation Explanation++## Beginner Level++### What Changed / What This Does++Creating a task or milestone pauses while Transit obtains its display ID. Previously, the selected project could be deleted during that pause, but creation would resume using its old in-memory project object and report success.++Transit now checks the project again after display-ID allocation, immediately before creating the dependent record. If the project was deleted, creation stops with the existing project-not-found behavior.++### Why It Matters++Callers no longer receive false success or leave behind a task or milestone after its required project has disappeared.++### Key Concepts++- **Race condition:** two operations overlap so an assumption that was true earlier is no longer true later.+- **Persistence boundary:** the final point before an object is inserted and saved.+- **Stale object:** an in-memory model that still looks valid even though another context deleted its stored row.++## Intermediate Level++### Changes Overview++`CreationProjectValidator` centralizes project-liveness checks for `TaskService.createTask` and `MilestoneService.createMilestone`. Both services call it after allocation and cancellation handling. `MilestoneService.Error` gains `projectNotFound`, with matching App Intent and MCP mappings.++Deterministic Swift Testing regressions suspend allocation, delete the project through a peer `ModelContext`, resume creation, and assert the exact domain error plus absence of pending and committed records.++### Implementation Approach++The validator reconciles two views of SwiftData state. The live context detects pending inserts and deletes, preserving valid unsaved projects and rejecting local pending deletion. A transient context checks committed storage so a stale registered project cannot hide a peer deletion. Fetch failures propagate rather than being converted to not-found.++### Trade-offs++A live-context-only check is cheaper but cannot detect a peer deletion hidden by registered state. A transient-only check rejects valid pending inserts. Combining both adds one bounded fetch per task or milestone creation after allocation, in exchange for correct persistence-boundary validation.++## Expert Level++### Technical Deep Dive++The defect was a TOCTOU window across `DisplayIDAllocator.allocateNextID`. MainActor isolation serializes individual execution segments but permits reentrancy at the allocation `await`. The post-await guard first inspects `deletedModelsArray`, then requires a live fetch, accepts a matching `insertedModelsArray` entry, and otherwise verifies committed existence through `ModelContext(modelContext.container)`. No suspension follows the check before the existing synchronous aggregate validation and `insertOrDelete` save path.++The implementation preserves CloudKit-compatible optional relationships, the shared container main context, pending insert semantics, creation-specific rollback behavior, and fail-closed fetch propagation. It mirrors the established fresh-context pattern used by `TaskCreationMilestoneValidator` without conflating project and milestone domain errors.++### Architecture Impact++Project existence is now an explicit service-layer invariant at both creation boundaries rather than a caller responsibility. Automation adapters preserve their existing public error contracts: App Intents return `PROJECT_NOT_FOUND`, MCP task and milestone creation return `No matching project found`, and UI callers receive the localized service error.++### Potential Issues++The transient committed-state fetch is additional creation-path I/O, but it is a single UUID lookup outside rendering and startup paths. SwiftData does not provide a transaction spanning CloudKit counter allocation and local model insertion, so no application-level probe can eliminate deletion occurring after the final check on a truly concurrent executor; the implementation closes the known suspension window and keeps all remaining service work synchronous on MainActor.++## Completeness Assessment++- **Fully implemented:** task and milestone service guards, live/pending and committed-state reconciliation, exact domain errors, App Intent and MCP mappings, deterministic service- and MCP-level peer-deletion regressions, changelog, and bugfix report.+- **Partially implemented:** none.+- **Missing:** none for T-2103. The full macOS and iOS suites, dedicated UI suite, lint/ownership validators, and iOS/macOS builds all pass.
specs/bugfixes/creation-after-project-deletion/report.md Added +117 / -0
diff --git a/specs/bugfixes/creation-after-project-deletion/report.md b/specs/bugfixes/creation-after-project-deletion/report.mdnew file mode 100644index 0000000..207b40c--- /dev/null+++ b/specs/bugfixes/creation-after-project-deletion/report.md@@ -0,0 +1,117 @@+# Bugfix Report: Creation After Project Deletion++**Date:** 2026-08-30+**Status:** Fixed+**Ticket:** T-2103++## Description of the Issue++Task and milestone creation can persist a new record after the selected project is deleted while display-ID allocation is suspended. Task creation leaves an orphan because the task relationship nullifies; milestone creation can still report success despite the project no longer existing.++**Reproduction steps:**+1. Persist a project and begin task or milestone creation for it.+2. Suspend the create inside asynchronous display-ID allocation.+3. Delete and save the project through an independent `ModelContext`, then resume allocation.+4. Observe that the create returns success and can persist a task or milestone after the project has gone.++**Impact:** Automation and UI callers can receive false success and persist records outside the required project aggregate. The create also consumes a display ID before using a project reference whose validity expired during suspension.++## Investigation Summary++The affected services, all creation callers, SwiftData relationship rules, existing stale-model validators, allocation gates, and related race regressions were inspected using the four-phase systematic-debugging workflow.++- **Symptoms examined:** peer-context deletion during allocation, stale registered project objects, orphan task persistence, milestone persistence, error contracts, and pending local project state.+- **Code inspected:** `TaskService.createTask`, `MilestoneService.createMilestone`, `Project`, `TaskCreationMilestoneValidator`, `DisplayIDRecordLookup`, `UsedDisplayIDs`, allocation-gated test support, and task/milestone automation adapters.+- **Hypotheses tested:** boundary adapters already validate the project, but that validation precedes the service's allocation await and cannot protect the persistence boundary; MainActor isolation serializes execution but does not make a method atomic across `await`; a normal live-context fetch can retain a registered snapshot, so committed state needs an independent context.++### Systematic inspection findings++1. **Data-flow defect — `TaskService.swift`:** both task overloads ultimately trust a `Project` model reference across `allocateNextID`. The UUID overload resolves the project before delegating, but there is no post-await existence check.+2. **Data-flow defect — `MilestoneService.swift`:** name uniqueness is rechecked after allocation, but project existence is not. Querying milestones by the deleted project's UUID does not prove that the project itself still exists.+3. **Integration/timing defect:** display-ID allocation is an intentional suspension point. Another MainActor job, context, device, or CloudKit merge can delete the project before execution resumes.+4. **SwiftData state defect:** the shared context can keep the passed project registered after an independent context commits deletion. A fresh transient context is required to observe committed absence, while the live context remains necessary to recognize pending inserts and deletes.+5. **Error-handling defect:** task creation already has `TaskService.Error.projectNotFound`; milestone creation has no equivalent domain error, so a service-level rejection cannot currently map to the established `PROJECT_NOT_FOUND` automation response.++## Discovered Root Cause++Project validation and project use are separated by an asynchronous display-ID allocation. The service assumes a model reference resolved before that suspension remains valid afterward. SwiftData model identity does not provide that guarantee: a peer delete can commit while the caller is suspended, and the receiving context can retain a stale registered object.++**Defect type:** TOCTOU race across `await`, with stale ORM identity/state.++**Five Whys:**+1. Why is a record created without a live project? Because creation inserts using the original `Project` object after allocation resumes.+2. Why is that object no longer valid? Because another context or sync merge deleted its stored row during the await.+3. Why does creation not notice? Because project lookup/validation occurs before the await and is not repeated at the persistence boundary.+4. Why is the existing object insufficient evidence? Because SwiftData contexts cache registered models and can lag peer-committed state.+5. Why can the store not enforce the aggregate automatically? CloudKit-compatible relationships are optional and use nullify/cascade rules; there is no transaction spanning the direct CloudKit counter allocation and SwiftData creation.++**Contributing factors:** MainActor code appears sequential despite yielding at every await; task and milestone creation accumulated targeted post-await cancellation/milestone/name safeguards without a shared project-liveness invariant; existing service tests covered stale selections before entry but not deletion during allocation.++## Resolution for the Issue++Added `CreationProjectValidator`, a shared persistence-boundary check that combines the two SwiftData state sources creation must respect:++1. Reject a matching project in the live context's pending-deletion set.+2. Require the project to remain fetchable from the live context, preserving valid unsaved project inserts.+3. Permit a matching pending insert without requiring committed storage.+4. For previously persisted projects, require a fresh transient context to still find the committed row, bypassing stale registered objects after peer deletion.+5. Propagate fetch failures so creation fails closed.++`TaskService.createTask` and `MilestoneService.createMilestone` invoke this validator after display-ID allocation and cancellation handling, immediately before their remaining synchronous validation and insertion work. Task creation reuses `TaskService.Error.projectNotFound`; milestone creation now exposes `MilestoneService.Error.projectNotFound`, which maps to the established `PROJECT_NOT_FOUND` App Intent response and deterministic MCP project-not-found message.++**Alternatives considered:** trusting only the passed model or the live-context fetch would retain the stale-object defect; using only a fresh context would incorrectly reject valid unsaved project inserts used by existing callers; changing relationship rules or replacing the shared main context would violate the app's SwiftData/CloudKit constraints. A pre-allocation check was omitted because it would not close the post-await race and would duplicate existing caller validation.++## Regression Test++**Test file:** `Transit/TransitTests/CreationProjectDeletionTests.swift`++**Test names:**+- `taskCreationRejectsProjectDeletedDuringAllocation`+- `mcpTaskCreationReportsProjectDeletionWithEstablishedError`+- `milestoneCreationRejectsProjectDeletedDuringAllocation`++**What they verify:** A deterministic allocation gate suspends each create, an independent context deletes the persisted project, and resumption must return a project-not-found domain error without leaving a pending or committed task/milestone. The MCP regression additionally requires `create_task` to preserve the established `No matching project found` tool error on this post-allocation path.++**Red checkpoint:** `run_silent make test-quick` compiled the test target and failed as expected before production changes. The result bundle reported 1,803 tests total: 1,801 passed and both new regressions failed. Task creation returned successfully when `TaskService.Error.projectNotFound` was expected; milestone creation likewise returned successfully instead of producing its project-not-found domain error.++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/CreationProjectValidator.swift` | Shared live/pending plus fresh committed-state project validation |+| `Transit/Transit/Services/TaskService.swift` | Post-allocation task project-liveness guard |+| `Transit/Transit/Services/MilestoneService.swift` | Post-allocation milestone project-liveness guard |+| `Transit/Transit/Services/MilestoneService+Error.swift` | Typed `projectNotFound` domain error and localized description |+| `Transit/Transit/Intents/IntentHelpers.swift` | Maps milestone project deletion to `PROJECT_NOT_FOUND` |+| `Transit/Transit/MCP/MCPToolHandler.swift` | Deterministic MCP task and milestone project-not-found responses |+| `Transit/TransitTests/MCPTestHelpers.swift` | Optional task counter-store injection for allocation-gated MCP coverage |+| `Transit/TransitTests/CreationProjectDeletionTests.swift` | Deterministic service and MCP peer-deletion regressions with exact error assertions |+| `CHANGELOG.md` | Unreleased bugfix entry |+| `specs/bugfixes/creation-after-project-deletion/report.md` | Investigation, resolution, and verification record |+| `specs/bugfixes/creation-after-project-deletion/implementation.md` | Three-level implementation explanation and completeness assessment |++## Verification++**Automated:**+- [x] Regression tests pass as part of `make test-quick`+- [x] macOS unit suite passes: 1,804 passed, 0 failed, 0 skipped+- [x] Full iOS scheme test passes: 1,288 top-level tests passed, 0 failed, 0 skipped (`make test`; 1,336 device-expanded passes)+- [x] Dedicated UI suite passes: 21 top-level tests passed, 0 failed, 0 skipped (`make test-ui`; 24 device-expanded passes)+- [x] Linters and SwiftData ownership validators pass (`make lint`)+- [x] iOS and macOS Debug builds pass (`make build`)++**Manual verification:** Not required; the gated two-context test reproduces the precise persistence window.++## Prevention++- Revalidate any model invariant after the last `await` and immediately before constructing/inserting dependent records.+- For SwiftData peer-race checks, combine live pending state with a fresh committed-state probe rather than trusting a registered model alone.+- Keep deterministic allocation gates for service methods whose persistence preconditions can expire while suspended.++## Related++- Transit T-2103+- T-1764 — post-allocation milestone name uniqueness recheck+- T-1765 — post-allocation cancellation recheck+- T-2020 — fresh committed-state probes after allocation+- T-2037 — live plus committed milestone validation before task insertion

Things to double-check

Fresh-context fetch cost

Every task and milestone creation now performs one additional UUID-scoped fetch. This is outside startup and rendering hot paths and is bounded to one record.

Cross-system atomicity

The CloudKit-backed display-ID allocation and SwiftData insertion are not one transaction. This branch correctly closes the application-level suspension window but cannot provide a store-level transaction across both systems.