transit PR #233 merge base 321cf90 head d025dca files 5 touched review threads 0 unresolved

PR #233 review: T-1858 milestone create dismissal

Ready / LGTM. The merge diff 321cf90..d025dca is the same T-1858 implementation already reviewed at bba5863, rebased over the current mainline. No actionable findings.

At a glance

  • Equivalence: the lifecycle helper, MilestoneEditView, focused lifecycle tests, and T-1858 report are byte-for-byte identical between bba5863 and d025dca.
  • Dismissal safety: iOS back and interactive dismissal are disabled while saving; shared onDisappear cancels only a pending create on iOS and macOS.
  • Persistence safety: the iOS regression run passed all four lifecycle tests and all six cancellation-boundary tests, including the milestone no-insertion case after cancellation during successful allocation.
  • Fresh CI: GitHub Actions claude-review attempt 2 completed successfully for d025dca at 2026-08-04T22:31:34Z.

Verdict

Ready / LGTM

The create-mode save task is retained by the view, cancellation is restricted to a pending save on disappearance, and success transitions before dismissal. This prevents dismissal from cancelling a successful save while preserving the existing service-level cancellation boundary that blocks post-cancel insertion. The only current UI failures exactly match the prior documented simulator-baseline failures and do not cover this path.

Commits

Three-level explanation

Creating a milestone happens asynchronously. Previously, leaving the editor could let the work continue without a UI owner. The editor now keeps the work task, blocks dismissal while the save is active, and cancels it only if the view actually disappears before success. A successfully saved milestone marks itself successful before closing, so closing the editor cannot accidentally cancel it.

MilestoneCreateSaveLifecycle is a small state machine: idlesaving → either cancellationPending or savedAwaitingDismissal. The state controls dismissal availability and makes the success/disappearance ordering explicit. The view owns the unstructured task in @State, while MilestoneService.createMilestone remains the persistence authority.

The review checked the cancellation edge after a non-cooperative allocator succeeds: MilestoneService calls Task.checkCancellation() after allocation and before model insertion. The UI cancellation task therefore reaches the existing T-1765 persistence boundary. Conversely, completeSave() reaches savedAwaitingDismissal before dismiss(); onDisappear then declines to cancel. This is final-source equivalent to the prior reviewed commit, not merely patch-similar.

Important changes — detailed

Create lifecycle owns dismissal state

Transit/Transit/Views/Settings/MilestoneCreateSaveLifecycle.swift

Why it matters. Makes the create operation's pending, cancellation, and success states explicit rather than inferring them from a view task.

What to look at. MilestoneCreateSaveLifecycle

Takeaway. For an async view mutation, represent the terminal pre-dismissal state separately from pending cancellation.
Rationale. A successful save must remain distinguishable from a pending save when the subsequent dismissal triggers onDisappear.

Editor cancels only a pending create

Transit/Transit/Views/Settings/MilestoneEditView.swift

Why it matters. Prevents a ghost milestone after a user leaves the editor while preserving success-driven dismissal.

What to look at. createMilestone(), cancelCreateSaveForDisappearance()

Takeaway. Store a user-initiated async operation in view state when its lifetime must be bound to that view.
Rationale. The save task is cancelled on true disappearance only while the lifecycle remains saving; success is recorded before dismiss().

Persistence boundary rejects post-cancel create

Transit/Transit/Services/MilestoneService.swift

Why it matters. A counter allocator may complete after cancellation, so the service must check cancellation immediately before insertion.

What to look at. MilestoneService.createMilestone(_:description:project:save:)

Takeaway. Re-check cooperative cancellation after every non-cooperative async boundary and before persistent mutation.
Rationale. The existing T-1765 guard is retained; focused iOS tests prove cancellation during a successful allocation leaves no milestone record.

Platform behavior and focused regressions

Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swift

Why it matters. Covers in-flight disappearance, success dismissal, save-failure retry, and cancellation completion while the shared source compiles for both platforms.

What to look at. MilestoneCreateSaveLifecycleTests; iOS toolbar/form integration

Takeaway. Keep platform controls thin and make their shared asynchronous lifecycle independently testable.
Rationale. iOS additionally disables the custom back button and interactive sheet dismissal; macOS receives the shared lifecycle cancellation through onDisappear.

Key decisions

Review final source snapshots rather than direct commit ancestry.

bba5863 and the PR head have different parents because the PR was rebased over 321cf90. A whole-commit diff includes unrelated T-1749 and T-1816 base deltas. Restricting comparison to the five T-1858 paths proves the lifecycle implementation, tests, and report are exactly equal.

Treat UI failures as a known simulator baseline, not a PR regression.

The fresh UI run has the same three failures recorded in the prior full review: TransitUITests.testClearAll, TransitUITests.testEditViewPreservesTaskMilestone, and DataMaintenanceUITests.testDataMaintenanceGoldenPath, accompanied by repeated LLDB version-store errors. The reviewed paths are unchanged from the prior clean snapshot, and the failures do not exercise milestone creation dismissal.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 53245a4..7525791 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -7,12 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased]  - T-1816: Regression coverage now directly proves that `query_tasks` validates malformed `status`, `not_status`, `type`, `priority`, `unfinished`, `search`, and `displayId` filters before either a missing milestone name or missing milestone display ID can return a successful no-match `[]`. This pins the validation order introduced by T-1608 / PR #217 without changing its production behavior. - T-1749: Visual Add Task no longer reports `NO_PROJECTS` when it cannot read the project table while resolving an unselected project. The existence check now propagates its SwiftData error through the existing injectable fetch seam, and the visual intent returns `INTERNAL_ERROR` with the storage-failure guidance. Genuine empty stores still return `NO_PROJECTS`; stale selected projects remain on the separate `PROJECT_NOT_FOUND` / storage-failure path.  - T-1711: MCP `get_projects` now returns the exact tool error `Failed to fetch milestones: <error>` when a project-scoped milestone fetch fails, rather than a successful partial list that omits milestone metadata. The scoped service fetch preserves its injected storage error; deterministic regressions cover the failure, an empty project's omitted `milestones` field, and correctly scoped milestones for another project.+- T-1858: New Milestone now retains its asynchronous create operation for the editor lifetime. iOS disables its back and interactive dismissal controls while a create is in flight; iOS/macOS disappearance cancels only a still-pending create, allowing T-1765's persistence-boundary cancellation check to prevent a ghost milestone. A successful create transitions to a distinct pre-dismissal state before calling `dismiss()`, so the successful dismissal never cancels itself. Focused lifecycle tests cover in-flight disappearance, successful dismissal, save-error retry, and cancellation completion; edit-mode merge/conflict behavior is unchanged. - T-1699: Sync heartbeat fetch failures no longer create duplicate `SyncHeartbeat` singleton rows. `SyncManager` now logs and skips a beat when its singleton fetch fails, then retries on the existing next timer interval; a successful empty fetch still creates the record and a successful existing fetch still updates it. The save remains best-effort and timer lifecycle is unchanged. Deterministic `SyncManagerTests` verify zero insertion, later recovery, and the existing/missing cases. - T-1838: Open report views now refresh relative ranges at the next relevant local calendar boundary and whenever the scene returns active. Report filtering and labels share one captured time/calendar snapshot, so they cannot straddle a boundary; scheduling is DST- and calendar-aware, uses a cancellable one-shot task rather than a polling timer, and leaves App Intent and absolute-range behavior unchanged. - T-1675: Project-scoped milestone-name lookups now preserve the exact SwiftData fetch failure through shared assignment as `INTERNAL_ERROR` (`Failed to look up milestone: <error>`), matching JSON/MCP create, update, and scoped-query adapters. The milestone service's injected fetcher is now available in MCP test setup; deterministic regressions prove no-match and ambiguity remain distinct, cross-project unscoped filtering is untouched, and failed lookups create or mutate nothing. - T-1825: Dashboard milestone filters now live-observe local, MCP, and CloudKit milestone changes, retaining selected Done or Abandoned rows within the active project scope so they remain visible and individually deselectable. Open rows remain first and follow their displayed title order; terminal selections are appended deterministically and deduplicated by UUID. Selected rows expose the native selected accessibility trait once, while inaccessible or deleted selections still leave Clear available. Add Task remains open-only.  - T-1657: Project lookup storage failures now remain distinguishable from missing projects across JSON intents, MCP create/query/milestone tools, and visual Add Task. `QueryMilestonesIntent` no longer returns a successful empty array when its project-name lookup is unreadable, while valid missing/ambiguous names and project-ID precedence retain their prior behavior. Visual Add Task now reports `INTERNAL_ERROR` for a failed project read rather than a stale-selection `PROJECT_NOT_FOUND`; deterministic failing-fetch regressions verify cross-surface parity and no task or milestone insertion.
Transit/Transit/Views/Settings/MilestoneCreateSaveLifecycle.swift Added +65 / -0
diff --git a/Transit/Transit/Views/Settings/MilestoneCreateSaveLifecycle.swift b/Transit/Transit/Views/Settings/MilestoneCreateSaveLifecycle.swiftnew file mode 100644index 0000000..b615237--- /dev/null+++ b/Transit/Transit/Views/Settings/MilestoneCreateSaveLifecycle.swift@@ -0,0 +1,65 @@+/// Models the create-mode save lifecycle for `MilestoneEditView`.+///+/// A successful save transitions before `dismiss()` so `onDisappear` cannot+/// cancel an operation that has already persisted. A pending create instead+/// transitions to cancellation, allowing the service's persistence-boundary+/// cancellation checks to prevent a ghost milestone.+nonisolated struct MilestoneCreateSaveLifecycle: Equatable {+    private enum State: Equatable {+        case idle+        case saving+        case cancellationPending+        case savedAwaitingDismissal+    }++    private var state: State = .idle++    /// Keeps navigation and interactive dismissal unavailable until either a+    /// save error/cancellation returns the editor to idle or the save dismisses it.+    var blocksDismissal: Bool {+        switch state {+        case .saving, .savedAwaitingDismissal:+            true+        case .idle, .cancellationPending:+            false+        }+    }++    var isCancellationPending: Bool {+        state == .cancellationPending+    }++    /// Starts one create operation. Repeated save actions are ignored while the+    /// current operation owns the lifecycle.+    mutating func beginSave() -> Bool {+        guard state == .idle else { return false }+        state = .saving+        return true+    }++    /// Marks a still-pending create for cancellation when its view disappears.+    /// A completed save intentionally returns false so its own `dismiss()` does+    /// not cancel the operation after persistence has succeeded.+    mutating func cancelForDisappearance() -> Bool {+        guard state == .saving else { return false }+        state = .cancellationPending+        return true+    }++    /// Records persistence success before the view is dismissed.+    mutating func completeSave() -> Bool {+        guard state == .saving else { return false }+        state = .savedAwaitingDismissal+        return true+    }++    mutating func completeFailure() {+        guard state == .saving else { return }+        state = .idle+    }++    mutating func completeCancellation() {+        guard state == .saving || state == .cancellationPending else { return }+        state = .idle+    }+}
Transit/Transit/Views/Settings/MilestoneEditView.swift Modified +40 / -8
diff --git a/Transit/Transit/Views/Settings/MilestoneEditView.swift b/Transit/Transit/Views/Settings/MilestoneEditView.swiftindex b1561b7..b5a4b85 100644--- a/Transit/Transit/Views/Settings/MilestoneEditView.swift+++ b/Transit/Transit/Views/Settings/MilestoneEditView.swift@@ -7,21 +7,26 @@ struct MilestoneEditView: View {     @Environment(\.dismiss) private var dismiss     @Environment(\.resolvedTheme) private var resolvedTheme      /// The draft, its baseline, and the load-once guard, held as one value so a     /// second `onAppear` cannot discard in-flight edits.     @State private var form = MilestoneEditForm()-    @State private var isSaving = false+    @State private var createSaveLifecycle = MilestoneCreateSaveLifecycle()+    @State private var createSaveTask: Task<Void, Never>?     @State private var errorMessage: String?      /// Set when a save finds fields that both the user and an external writer     /// changed. Presence drives the conflict alert.     @State private var pendingConflict: MilestoneEditMerge?      private var isEditing: Bool { milestone != nil } +    private var isSaving: Bool {+        createSaveLifecycle.blocksDismissal+    }+     var body: some View {         formContent             .alert("Save Failed", isPresented: $errorMessage.isPresent) {                 Button("OK") { errorMessage = nil }             } message: {                 Text(errorMessage ?? "")@@ -29,12 +34,13 @@ struct MilestoneEditView: View {             .editConflictAlert(                 subject: "Milestone",                 conflict: $pendingConflict,                 keepMine: { saveExisting(consentingTo: $0) },                 useTheirs: { adoptLiveValues(for: $0) }             )+            .onDisappear { cancelCreateSaveForDisappearance() }     }      private var formContent: some View {         #if os(macOS)         macOSForm         #else@@ -54,12 +60,13 @@ struct MilestoneEditView: View {             }         }         .navigationTitle(isEditing ? "Edit Milestone" : "New Milestone")         .navigationBarTitleDisplayMode(.inline)         .navigationBarBackButtonHidden(true)         .toolbar { editToolbar }+        .interactiveDismissDisabled(isSaving)         .onAppear { loadMilestone() }     }     #endif      // MARK: - macOS Layout @@ -109,12 +116,13 @@ struct MilestoneEditView: View {     @ToolbarContentBuilder     private var editToolbar: some ToolbarContent {         ToolbarItem(placement: .cancellationAction) {             Button { dismiss() } label: {                 Image(systemName: "chevron.left")             }+            .disabled(isSaving)         }         ToolbarItem(placement: .confirmationAction) {             Button("Save", systemImage: "checkmark") { save() }                 .disabled(!form.canSave || isSaving)         }     }@@ -124,13 +132,13 @@ struct MilestoneEditView: View {     private func loadMilestone() {         guard let milestone else { return }         form.load(from: milestone)     }      private func save() {-        guard form.canSave else { return }+        guard form.canSave, !isSaving else { return }         if milestone == nil {             createMilestone()         } else {             saveExisting()         }     }@@ -163,28 +171,52 @@ struct MilestoneEditView: View {             return         }         dismiss()     }      private func createMilestone() {+        guard createSaveLifecycle.beginSave() else { return }         let edited = form.edited-        isSaving = true-        Task {-            defer { isSaving = false }++        let saveTask = Task { @MainActor in             do {                 try await milestoneService.createMilestone(                     name: edited.name,                     description: edited.description.isEmpty ? nil : edited.description,                     project: project                 )+                try Task.checkCancellation()++                // Record success before dismissal so the resulting `onDisappear`+                // cannot cancel an operation that has already persisted.+                guard createSaveLifecycle.completeSave() else { return }+                createSaveTask = nil+                dismiss()+            } catch is CancellationError {+                finishCancelledCreate()             } catch {-                errorMessage = error.localizedDescription-                return+                if Task.isCancelled {+                    finishCancelledCreate()+                } else {+                    createSaveLifecycle.completeFailure()+                    createSaveTask = nil+                    errorMessage = error.localizedDescription+                }             }-            dismiss()         }+        createSaveTask = saveTask+    }++    private func cancelCreateSaveForDisappearance() {+        guard createSaveLifecycle.cancelForDisappearance() else { return }+        createSaveTask?.cancel()+    }++    private func finishCancelledCreate() {+        createSaveLifecycle.completeCancellation()+        createSaveTask = nil     }      /// Recomputes before using external values. Changed conflict fields or     /// values cause a new alert; otherwise the form performs a complete safe     /// rebase and remains open for review.     private func adoptLiveValues(for shownConflict: MilestoneEditMerge) {
Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swift Added +64 / -0
diff --git a/Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swift b/Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swiftnew file mode 100644index 0000000..bf1e694--- /dev/null+++ b/Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swift@@ -0,0 +1,64 @@+import Foundation+import Testing+@testable import Transit++/// Regression tests for T-1858. The editor must retain and cancel a create+/// operation only while it is truly in flight; a completed save must be allowed+/// to dismiss without its own disappearance being treated as cancellation.+@MainActor+struct MilestoneCreateSaveLifecycleTests {++    @Test func disappearanceDuringInFlightCreateRequestsCancellation() {+        var lifecycle = MilestoneCreateSaveLifecycle()++        let didBegin = lifecycle.beginSave()+        let blocksWhileSaving = lifecycle.blocksDismissal+        let didRequestCancellation = lifecycle.cancelForDisappearance()++        #expect(didBegin)+        #expect(blocksWhileSaving)+        #expect(didRequestCancellation)+        #expect(lifecycle.isCancellationPending)+    }++    @Test func successfulSaveDoesNotCancelWhenDismissalMakesViewDisappear() {+        var lifecycle = MilestoneCreateSaveLifecycle()++        let didBegin = lifecycle.beginSave()+        let didCompleteSave = lifecycle.completeSave()+        let blocksWhileDismissing = lifecycle.blocksDismissal+        let didRequestCancellation = lifecycle.cancelForDisappearance()++        #expect(didBegin)+        #expect(didCompleteSave)+        #expect(blocksWhileDismissing)+        #expect(didRequestCancellation == false)+        #expect(lifecycle.isCancellationPending == false)+    }++    @Test func failedSaveReenablesDismissalAndRetry() {+        var lifecycle = MilestoneCreateSaveLifecycle()++        let didBegin = lifecycle.beginSave()+        lifecycle.completeFailure()+        let blocksAfterFailure = lifecycle.blocksDismissal+        let didRetry = lifecycle.beginSave()++        #expect(didBegin)+        #expect(blocksAfterFailure == false)+        #expect(didRetry)+    }++    @Test func completingCancellationReenablesTheEditor() {+        var lifecycle = MilestoneCreateSaveLifecycle()++        let didBegin = lifecycle.beginSave()+        let didRequestCancellation = lifecycle.cancelForDisappearance()+        lifecycle.completeCancellation()++        #expect(didBegin)+        #expect(didRequestCancellation)+        #expect(lifecycle.blocksDismissal == false)+        #expect(lifecycle.isCancellationPending == false)+    }+}
specs/bugfixes/milestone-create-dismissal/report.md Added +102 / -0
diff --git a/specs/bugfixes/milestone-create-dismissal/report.md b/specs/bugfixes/milestone-create-dismissal/report.mdnew file mode 100644index 0000000..8c8904d--- /dev/null+++ b/specs/bugfixes/milestone-create-dismissal/report.md@@ -0,0 +1,102 @@+# Bugfix Report: Milestone Create Dismissal++**Date:** 2026-08-05+**Status:** Fixed++## Description of the Issue++`MilestoneEditView` starts create-mode persistence in an unretained `Task`. While display-ID allocation is awaiting, the iOS back control remains available and macOS navigation can replace the editor. The view disappears but the operation continues, eventually persisting a milestone that the user had cancelled.++**Reproduction steps:**+1. Open **New Milestone** and enter a valid name.+2. Tap Save while display-ID allocation is slow or offline.+3. Navigate back on iOS, or change the Settings category on macOS.+4. Wait for allocation to finish.+5. Observe that the milestone is persisted despite the editor having been dismissed.++**Impact:** A cancellation can create an unexpected milestone. The risk is highest when CloudKit display-ID allocation suspends for a noticeable time.++## Investigation Summary++### Phase 1 — Initial overview++Expected behavior: the create operation either completes while the editor remains active, or a true view disappearance cancels the pending operation before persistence.++Actual behavior: the create task outlives the editor. The UI does not represent a cancellation boundary for the async create.++### Phase 2 — Systematic inspection++- `Transit/Transit/Views/Settings/MilestoneEditView.swift` creates an unstructured `Task` and does not retain it.+- The iOS custom back button is not disabled while `isSaving` is true; sheet-style interactive dismissal is also not blocked.+- No `onDisappear` lifecycle hook cancels an in-flight create, so macOS Settings-category navigation can also orphan the operation.+- `Transit/Transit/Services/MilestoneService.swift` already preserves the T-1765 invariant: after the allocation await it calls `Task.checkCancellation()` before constructing or saving a `Milestone`.+- Edit mode is synchronous and uses merge/conflict behavior; it must remain unchanged.++### Phase 3 — Root cause analysis++1. Why does dismissal persist a milestone? The create task continues after the view disappears.+2. Why does it continue? The view does not retain the task or cancel it on disappearance.+3. Why can the view disappear during save? The iOS back control remains enabled, and macOS navigation can replace the detail stack.+4. Why would cancellation be safe? T-1765 checks cancellation after allocation and immediately before the persistence boundary.+5. Root cause: `MilestoneEditView` has no explicit state machine separating an in-flight save from a successfully completed save that is about to dismiss.++**Defect type:** Async lifecycle race / missing cancellation boundary.++**Assumption validated:** The T-1765 service guard is after the only suspension in `createMilestone` and before insertion, so cancellation while allocation is pending cannot persist a milestone.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Views/Settings/MilestoneEditView.swift` — retains the create-mode `Task`, disables the iOS back and interactive dismissal paths while saving, and cancels a still-pending operation when the editor disappears on either platform. Successful persistence marks completion before `dismiss()`, so that disappearance is intentionally not cancelled. Cancellation errors stay silent; ordinary save errors still remain in the editor and surface through the existing alert. Edit-mode merge/conflict save behavior is unchanged.+- `Transit/Transit/Views/Settings/MilestoneCreateSaveLifecycle.swift` — adds the small lifecycle state helper separating `saving`, `cancellationPending`, and `savedAwaitingDismissal`.+- `Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swift` — adds deterministic lifecycle regressions.++**Approach rationale:** The retained operation gives `onDisappear` a concrete cancellation target. The lifecycle helper ensures cancellation is requested only before the service returns; T-1765’s post-allocation `Task.checkCancellation()` then guarantees that a true cancellation cannot cross the milestone insert/save boundary. Marking success before dismissal prevents the successful route from being mistaken for cancellation.++**Alternatives considered:**+- Disable only the iOS back button — rejected because it does not cover swipe navigation or macOS category/detail replacement.+- Cancel every operation from `onDisappear` — rejected because a completed save’s `dismiss()` would spuriously cancel its own task.+- Change `MilestoneService` persistence behavior — rejected because T-1765 already provides the necessary cancellation check and the defect is view lifecycle ownership.++## Regression Test++**Test file:** `Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swift`++**Test names:** `disappearanceDuringInFlightCreateRequestsCancellation`, `successfulSaveDoesNotCancelWhenDismissalMakesViewDisappear`, `failedSaveReenablesDismissalAndRetry`, and `completingCancellationReenablesTheEditor`.++**What they verify:** The view-state model requests cancellation only for a genuinely pending create, keeps successful dismissal distinct from cancellation, and recovers after errors or cancellation. Existing `CancelledCreateTests` verifies T-1765 prevents a milestone from persisting after cancellation during allocation.++**Run commands:** `make test-quick`; targeted iOS `xcodebuild test` for `MilestoneCreateSaveLifecycleTests` and `CancelledCreateTests`.++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Views/Settings/MilestoneEditView.swift` | Retain/cancel create task and gate iOS dismissal. |+| `Transit/Transit/Views/Settings/MilestoneCreateSaveLifecycle.swift` | Testable create-save lifecycle state helper. |+| `Transit/TransitTests/MilestoneCreateSaveLifecycleTests.swift` | Lifecycle regressions for T-1858. |+| `CHANGELOG.md` | Records the user-visible lifecycle fix. |++## Verification++**Automated:**+- [x] `make test-quick` passes.+- [x] Targeted iOS `MilestoneCreateSaveLifecycleTests` and `CancelledCreateTests` pass.+- [x] `make lint` passes.+- [ ] `make test` did not reach completion before the runner’s five-minute timeout. It compiled the iOS app and ran a large portion of `TransitTests` without a reported failure before timing out.+- [ ] `make test-ui` has unrelated existing failures in `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`, accompanied by simulator debugger-version-store errors. No UI test exercises milestone creation.++**Manual verification:**+- Not run: a deterministic UI reproduction needs a delayed allocator injection seam that the production UI-test harness does not expose. The lifecycle helper plus T-1765’s deterministic service tests cover the relevant cancellation boundary.++## Prevention++- Async view-owned persistence must retain its task and give disappearance a deliberate cancellation policy.+- Represent “save completed and now dismissing” separately from “save in progress” so lifecycle hooks cannot cancel successful work.+- Keep cancellation checks at service persistence boundaries for non-cooperative external awaits (T-1765).++## Related++- T-1858 — Milestone creation continues after the editor is dismissed.+- T-1765 — Pre-cancelled creates can still persist records.+- `Transit/Transit/Views/AddTask/AddTaskSheet.swift` — existing iOS save-dismissal guard pattern.

Things to double-check

Fresh local validation

make test-quick and make lint passed. A focused iOS Simulator run passed all four MilestoneCreateSaveLifecycleTests and all six CancelledCreateTests. The broad make test command did not complete before the 120-second harness limit while its UI runner emitted the same LLDB-version-store failures; do not read that incomplete run as a pass.

Fresh remote CI and review state

GitHub Actions run 30956023681, attempt 2, succeeded against d025dca2221feb267db78edd4019844a421c581f. The pre-post GraphQL inspection found zero unresolved review threads.