transit branch T-1814/bugfix-add-task-intent-misclassifies-stale-last-project-selection commits 5 files 4 touched lines +167 / -15 tests 1,670 + 9 focused passed

Pre-push review: T-1814/bugfix-add-task-intent-misclassifies-stale-last-project-selection

Review of PR #209 at the exact requested head 4cfe3f739a3d7edac448a05e18dcde9a1066ce61 against base bf7fd7de32155b697293ab3d8e18ff3dc1659285. The candidate is clean, identity-matched, patch-equivalent to GitHub, and satisfies the requested AddTaskIntent behavior.

At a glance

  • Identity: local HEAD, PR head, and requested head are all 4cfe3f739a3d7edac448a05e18dcde9a1066ce61; requested base resolves to bf7fd7de32155b697293ab3d8e18ff3dc1659285.
  • Fresh review/CI: claude-review completed successfully on the exact head at 2026-08-02T06:55:54Z; GraphQL reports zero review threads.
  • Validation: lint passed; full macOS unit tests passed with 1,670 tests and no failures; focused AddTaskIntentTests passed all 9 tests.
  • Semantic equivalence: local and GitHub PR patch IDs are both 98aecb872d341aa02aeb2e0bd99c4a8dd58c5cae; git diff --check passed.
  • Scope: no source changes, push, or merge were made during this review; only the requested review artifact is regenerated.

Verdict

Ready to push

The worktree is clean and exactly at the requested head. GitHub PR #209 has the requested base/head, a successful current-head claude-review check, and zero review threads. make lint, make test-quick (1,670 passed, 0 failed), and the focused AddTaskIntentTests run (9 passed, 0 failed) all pass. The required parameter remains non-optional, stale selections return PROJECT_NOT_FOUND with zero or remaining projects, no-selection distinguishes NO_PROJECTS from INVALID_INPUT, and all failure paths are mutation-free.

Review findings

1 raised · 1 fixed · 0 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The Add Task intent used to check whether any projects existed before checking whether the selected project still existed. If a saved Shortcut selection referred to a project that had been deleted, and that was the last project, the intent incorrectly reported NO_PROJECTS.

Why it matters

The intent now resolves a supplied project entity first. A stale selection reports PROJECT_NOT_FOUND, while a genuinely absent selection reports NO_PROJECTS only for an empty store and INVALID_INPUT when projects exist. Task creation remains after validation, so failures do not create tasks.

Control flow

The framework-facing @Parameter var project: ProjectEntity remains required. Only the testable static execute seam accepts an optional entity so the absent-input branches can be tested. The nil branch checks hasAnyProjects(); the non-nil branch calls findProject(id:) before invoking TaskService.createTask.

Trade-off

This preserves the Shortcuts contract without adding deletion tombstones or inferring state from serialized entity fields. The only extra fetch is on the test-only missing-selection path.

Invariant preserved

Entity resolution precedes task mutation, and the supplied entity's UUID determines classification independently of current project cardinality. Therefore stale selection maps to projectNotFound for both zero and nonzero remaining projects. Nil selection is the only path that consults current cardinality, yielding noProjects for an empty store and invalidInput otherwise.

Boundary verification

The implementation leaves perform() and the App Intents metadata-facing property unchanged while widening only the internal execution seam. Focused tests cover the four failure combinations and assert exact associated error values plus an empty TransitTask fetch after each failure.

Important changes — detailed

Resolve supplied project entities before classifying absence

Transit/Transit/Intents/Visual/AddTaskIntent.swift

Why it matters. This fixes the reported boundary-condition bug: a stale saved Shortcut selection remains distinguishable from an empty current database.

What to look at. AddTaskIntent.swift:76-93

Takeaway. When serialized entity selections can outlive model records, resolve an explicit entity before using aggregate-store checks to classify errors.
Rationale. The selected UUID is the only reliable evidence that the caller supplied a stale selection; project count cannot preserve that distinction.

Preserve the required App Intents parameter contract

Transit/Transit/Intents/Visual/AddTaskIntent.swift

Why it matters. Shortcuts must continue treating Project as a required parameter; making the framework-facing property optional would be a contract regression.

What to look at. AddTaskIntent.swift:29-30, 58-64

Takeaway. Keep framework metadata-facing parameters strict and widen only a testable internal seam when an absent-input branch must be exercised.
Rationale. The patch keeps the public App Intents declaration unchanged and represents missing input only in the static execution helper.

Add exact-error and no-mutation regression coverage

Transit/TransitTests/AddTaskIntentTests.swift

Why it matters. The bug is error classification, so tests must assert the exact associated enum value and prove no task was persisted on every failure path.

What to look at. AddTaskIntentTests.swift:135-225

Takeaway. For validation failures around persistence, assert both typed error identity and post-failure storage state.
Rationale. The tests explicitly cover empty/no-selection, nonempty/no-selection, stale/zero-project, and stale/remaining-project states.

Record the investigation and behavioral contract

specs/bugfixes/add-task-intent-stale-project-selection/report.md

Why it matters. The report documents the root cause, alternatives, exact expected errors, and verification evidence for future review.

What to look at. report.md:1-99

Takeaway. Bugfix reports are most useful when they preserve the failing boundary condition and the mutation-safety invariant, not only the final diff.
Rationale. The repository's established bugfix-report convention is used, including red checkpoint and verification sections.

Key decisions

Resolve explicit selection before project-count classification

The stale-selection identity is carried by the supplied entity's project UUID. Current project count is consulted only when no entity was supplied, so NO_PROJECTS is reserved for a genuinely empty store.

Use an optional execution seam, not an optional App Intents parameter

The framework-facing property remains ProjectEntity, preserving required-parameter metadata. The internal static helper accepts ProjectEntity? solely to make missing-input behavior directly testable.

Review findings

SeverityAreaFindingResolution
majorApp Intents parameter contractAn earlier review identified that making the framework-facing Project parameter optional would remove the required Shortcuts contract.The current candidate preserves <code>@Parameter var project: ProjectEntity</code>; only execute(project:) is optional. Source inspection and the exact-head review confirm the issue is resolved.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 1ed71ef..9f7d464 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - T-1799: QueryTasksIntent absolute date filters now require exact ASCII `YYYY-MM-DD` values representing valid dates in the user's current calendar. Local `Calendar.current` parsing, non-lenient formatter round-trip validation, and regressions reject normalized invalid days/months, non-padded values, and non-ASCII input while preserving inclusive ranges and relative filters. - MCP Streamable HTTP now returns `405 Method Not Allowed` with `Allow: POST` for `GET /mcp` when no SSE listening stream is offered, while preserving POST handling, origin validation, and unrelated-route 404 behavior (T-1835). - MCP `query_tasks` now verifies that a well-formed `projectId` identifies an existing project before filtering (T-1783). Nonexistent project IDs return the established project-not-found error, while malformed UUID validation and other filters remain unchanged; regression coverage also checks parity with `QueryTasksIntent`.+- `AddTaskIntent` now distinguishes an empty-database/no-selection path (`NO_PROJECTS`) from a stale selected `ProjectEntity` (`PROJECT_NOT_FOUND`) even when the deleted project was the last project in the store (T-1814). The required App Intents project parameter contract is preserved; missing project input against a non-empty store returns `INVALID_INPUT`. Entity resolution happens before task creation, and focused regressions assert exact error cases and no task mutation. - MCP `create_task`, `create_milestone`, `CreateTaskIntent`, and `CreateMilestoneIntent` now distinguish missing required fields from present non-string `name`/`type` values (T-1598). Numeric, boolean, array, object, and null inputs return field-specific `name must be a string` / `type must be a string` validation errors before mutation, while missing-field and invalid-string-type behavior remains unchanged. Symmetric regression coverage verifies all four create paths and confirms malformed requests create no records. - `QueryTasksIntent` now rejects explicit JSON `null` in nested `completionDate` and `lastStatusChangeDate` fields (`relative`, `from`, and `to`) before Codable decoding can erase presence. Malformed nested date-filter shapes retain the established `INVALID_INPUT` contract, omitted fields remain unchanged, and regressions cover both filters and no-broadening behavior (T-1644). - `Color.hexString` now rounds resolved RGB components to the nearest byte after finite-checking and clamping instead of truncating values just below byte boundaries (T-1807). Project colors no longer darken when an editor saves an unchanged color. Regression coverage exercises every `0...255` RGB value, deterministic sRGB resolved components immediately below each boundary, and the existing uppercase six-digit RGB/no-alpha format.
Transit/Transit/Intents/Visual/AddTaskIntent.swift Modified +7 / -2
diff --git a/Transit/Transit/Intents/Visual/AddTaskIntent.swift b/Transit/Transit/Intents/Visual/AddTaskIntent.swiftindex d2942b9..2ee80f2 100644--- a/Transit/Transit/Intents/Visual/AddTaskIntent.swift+++ b/Transit/Transit/Intents/Visual/AddTaskIntent.swift@@ -59,7 +59,7 @@ struct AddTaskIntent: AppIntent {         name: String,         taskDescription: String?,         type: TaskType,-        project: ProjectEntity,+        project: ProjectEntity?,         services: Services,         persistence: PersistenceAvailability = .shared     ) async throws -> TaskCreationResult {@@ -73,7 +73,12 @@ struct AddTaskIntent: AppIntent {             throw VisualIntentError.invalidInput("Name is required.")         } -        guard services.projectService.hasAnyProjects() else {+        // Keep no selection distinct from a stale entity so the latter can report+        // PROJECT_NOT_FOUND even when the deleted project was the last one.+        guard let project else {+            if services.projectService.hasAnyProjects() {+                throw VisualIntentError.invalidInput("Project is required.")+            }             throw VisualIntentError.noProjects         } 
Transit/TransitTests/AddTaskIntentTests.swift Modified +60 / -13
diff --git a/Transit/TransitTests/AddTaskIntentTests.swift b/Transit/TransitTests/AddTaskIntentTests.swiftindex 36a65a0..336767a 100644--- a/Transit/TransitTests/AddTaskIntentTests.swift+++ b/Transit/TransitTests/AddTaskIntentTests.swift@@ -134,29 +134,22 @@ struct AddTaskIntentTests {      @Test func executeThrowsNoProjectsWhenDatabaseIsEmpty() async throws {         let svc = try makeServices()-        let fakeProject = ProjectEntity(-            id: UUID().uuidString,-            projectId: UUID(),-            name: "Missing"-        )          do {             _ = try await AddTaskIntent.execute(                 name: "Task",                 taskDescription: nil,                 type: .feature,-                project: fakeProject,+                project: nil,                 services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project)             )             Issue.record("Expected noProjects error")         } catch let error as VisualIntentError {-            switch error {-            case .noProjects:-                break-            default:-                Issue.record("Expected noProjects error, got \(error.code)")-            }+            #expect(error == .noProjects)         }++        let tasks = try svc.context.fetch(FetchDescriptor<TransitTask>())+        #expect(tasks.isEmpty)     }      @Test func executeThrowsProjectNotFoundForStaleProjectSelection() async throws {@@ -166,7 +159,7 @@ struct AddTaskIntentTests {         svc.context.delete(project)         try svc.context.save() -        await #expect(throws: VisualIntentError.self) {+        do {             _ = try await AddTaskIntent.execute(                 name: "Task",                 taskDescription: nil,@@ -174,6 +167,60 @@ struct AddTaskIntentTests {                 project: selectedProject,                 services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project)             )+            Issue.record("Expected projectNotFound error")+        } catch let error as VisualIntentError {+            #expect(error == .projectNotFound("Selected project no longer exists."))+        }++        let tasks = try svc.context.fetch(FetchDescriptor<TransitTask>())+        #expect(tasks.isEmpty)+    }++    @Test func executeThrowsInvalidInputForMissingProjectWhenProjectsExist() async throws {+        let svc = try makeServices()+        _ = makeProject(in: svc.context)++        do {+            _ = try await AddTaskIntent.execute(+                name: "Task",+                taskDescription: nil,+                type: .feature,+                project: nil,+                services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project)+            )+            Issue.record("Expected invalidInput error")+        } catch let error as VisualIntentError {+            #expect(error == .invalidInput("Project is required."))         }++        let tasks = try svc.context.fetch(FetchDescriptor<TransitTask>())+        #expect(tasks.isEmpty)+    }++    @Test func executeThrowsProjectNotFoundForStaleProjectSelectionWhenAnotherProjectRemains() async throws {+        let svc = try makeServices()+        let deletedProject = makeProject(in: svc.context, name: "Deleted Project")+        _ = makeProject(in: svc.context, name: "Remaining Project")+        let selectedProject = makeEntity(from: deletedProject)+        svc.context.delete(deletedProject)+        try svc.context.save()++        #expect(svc.project.hasAnyProjects())++        do {+            _ = try await AddTaskIntent.execute(+                name: "Task",+                taskDescription: nil,+                type: .feature,+                project: selectedProject,+                services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project)+            )+            Issue.record("Expected projectNotFound error")+        } catch let error as VisualIntentError {+            #expect(error == .projectNotFound("Selected project no longer exists."))+        }++        let tasks = try svc.context.fetch(FetchDescriptor<TransitTask>())+        #expect(tasks.isEmpty)     } }
specs/bugfixes/add-task-intent-stale-project-selection/report.md Added +99 / -0
diff --git a/specs/bugfixes/add-task-intent-stale-project-selection/report.md b/specs/bugfixes/add-task-intent-stale-project-selection/report.mdnew file mode 100644index 0000000..4af7fdc--- /dev/null+++ b/specs/bugfixes/add-task-intent-stale-project-selection/report.md@@ -0,0 +1,99 @@+# Bugfix Report: AddTaskIntent Stale Project Selection++**Date:** 2026-08-02+**Status:** Fixed++## Description of the Issue++`AddTaskIntent` checks whether any projects exist before resolving the selected `ProjectEntity`. When a Shortcut retains a project entity whose project was deleted, and that project was the last project in Transit, the intent throws `VisualIntentError.noProjects` instead of the more useful `VisualIntentError.projectNotFound`.++**Reproduction steps:**+1. Create one project and select it in an Add Task Shortcut.+2. Delete that project so the database contains no projects.+3. Run the Shortcut with its saved project selection.+4. Observe that the intent reports `NO_PROJECTS` instead of `PROJECT_NOT_FOUND`.++**Impact:** A stale Shortcut receives incorrect recovery guidance. It tells the user to create a project rather than explaining that the selected project was deleted and needs to be replaced. No task should be created in this failure path.++## Investigation Summary++The selected entity, intent execution path, project resolver, callers, requirements, and existing tests were inspected line by line.++- **Symptoms examined:** stale selection with another project present, stale selection after deleting the last project, and an empty database with no usable selection.+- **Code inspected:** `AddTaskIntent.execute`, `ProjectEntity`, `ProjectEntityQuery`, `ProjectService.findProject`, all `AddTaskIntent.execute` callers, and `AddTaskIntentTests`.+- **Hypotheses tested:** the failure is not in task creation or entity serialization; the early project-count guard prevents the selected entity from reaching `ProjectService.findProject`.++### Systematic inspection findings++1. **Control-flow defect:** `AddTaskIntent.execute` calls `hasAnyProjects()` before resolving `project.projectId`.+2. **Boundary-condition defect:** the early guard collapses two distinct states when the project count is zero: no project was selected versus a selected project was deleted.+3. **Error-classification defect:** the stale entity cannot receive `projectNotFound`, even though `ProjectService.findProject` would correctly report that the UUID is absent.+4. **Mutation safety:** task creation occurs after project resolution, so the failure path currently creates no task; the regression test now asserts this explicitly.++## Discovered Root Cause++The no-project check has higher precedence than selected-entity resolution. Because the check observes only current database contents, it reports `noProjects` before the intent can determine whether the caller supplied a stale selection.++**Defect type:** Control-flow logic error and boundary-condition misclassification.++**Why it occurred:** The original implementation treated a required project parameter and an empty database as mutually exclusive, but did not account for persisted Shortcut parameters surviving deletion of their referenced project.++**Contributing factors:** `ProjectEntity` values are snapshots used by Shortcuts, while `ProjectService` resolves against current SwiftData state. A saved entity can therefore outlive its model record.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Intents/Visual/AddTaskIntent.swift` — Keeps the visual intent's project parameter required at the App Intents boundary, while the testable execution path represents an absent selection explicitly. A nil selection throws `.noProjects` only when the database is empty and otherwise throws `.invalidInput`; a supplied entity is resolved through `ProjectService.findProject` before task creation. A stale entity therefore throws `.projectNotFound` even when the current project store is empty.+- `Transit/TransitTests/AddTaskIntentTests.swift` — Tightened stale-selection coverage to assert the exact associated enum value and verify that no task is persisted when the stale project was the only project or when another project remains. Updated the empty-database/no-selection test to assert exact `.noProjects` and verify that no task is persisted, and covered missing selection with existing projects as invalid input.+- `CHANGELOG.md` — Added the T-1814 fix to the Unreleased Fixed section.++**Approach rationale:** The App Intents parameter remains required, preserving the Shortcuts UI contract. The testable execution seam uses nil to represent an absent input: it reports `.noProjects` only when the store is empty and `.invalidInput` when projects exist. Resolving a supplied entity first preserves the identity of a stale Shortcut selection independently of how many projects remain. The existing task creation boundary remains after validation and resolution, so all failure cases are mutation-free.++**Alternatives considered:**+- Keep the early `hasAnyProjects()` guard and inspect the count after failure — rejected because an empty store cannot distinguish a deleted selected project from no selection.+- Infer selection state from `ProjectEntity.id`, `projectId`, or `name` — rejected because those are serialized entity fields and should not carry control-flow meaning beyond the selected entity's UUID.+- Add deletion tombstones to `ProjectService` — rejected because deletion can happen through sync or another context, and it would add state solely to compensate for an ambiguous API.++## Regression Test++**Test file:** `Transit/Transit/TransitTests/AddTaskIntentTests.swift`+**Test names:** `executeThrowsProjectNotFoundForStaleProjectSelection`, `executeThrowsProjectNotFoundForStaleProjectSelectionWhenAnotherProjectRemains`, `executeThrowsNoProjectsWhenDatabaseIsEmpty`, and `executeThrowsInvalidInputForMissingProjectWhenProjectsExist`++**What they verify:** A stale selected entity throws the exact `.projectNotFound("Selected project no longer exists.")` case both after the selected project was the only project and after another project remains, an absent selection in an empty store throws exact `.noProjects`, and an absent selection with existing projects throws exact `.invalidInput("Project is required.")`. Each failure path verifies that no task is created.++**Run command:** `make test-quick`++**Red checkpoint:** The tightened test failed before implementation because the actual error was `.noProjects`.++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Intents/Visual/AddTaskIntent.swift` | Distinguish nil selection from stale selected entity before creating a task |+| `Transit/TransitTests/AddTaskIntentTests.swift` | Exact error and no-mutation regressions for stale and empty selection cases |+| `CHANGELOG.md` | Unreleased T-1814 fix entry |+| `specs/bugfixes/add-task-intent-stale-project-selection/report.md` | Investigation, resolution, and verification record |++## Verification++**Automated:**+- [x] Regression tests pass — `executeThrowsProjectNotFoundForStaleProjectSelection` and `executeThrowsProjectNotFoundForStaleProjectSelectionWhenAnotherProjectRemains` assert the exact `.projectNotFound` value for stale selections with zero and remaining projects, `executeThrowsNoProjectsWhenDatabaseIsEmpty` asserts exact `.noProjects`, and `executeThrowsInvalidInputForMissingProjectWhenProjectsExist` asserts exact `.invalidInput`; all four verify no task creation.+- [x] Full macOS unit test suite passes — `make test-quick` result: 1,651 passed, 0 failed.+- [~] Full iOS test suite executed — 1,184 passed, 3 failed; the failures are the documented pre-existing iOS 26.5 UI baseline (`TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`) and do not touch AddTaskIntent.+- [~] UI tests executed — 18 passed, 3 failed; the same documented baseline failures occurred, with no AddTaskIntent UI regression.+- [x] Linters/validators pass — `make lint` reports 0 violations and all ownership guard checks pass.+- [x] Builds pass for iOS and macOS — `make build` reports both Build Succeeded.++**Manual verification:**+- Not performed; automated tests cover both error classifications and mutation safety.++## Prevention++- Resolve an explicitly supplied entity before classifying an empty current database.+- Keep separate regression coverage for a stale selected entity and true no-selection/empty-database behavior.+- Assert exact typed error cases and absence of mutations in intent failure tests.++## Related++- Transit T-1814+- `specs/shortcuts-friendly-intents/requirements.md` requirement 7.8

Things to double-check

CI qualification

The repository reports no required checks through gh pr checks --required, but the current exact head has a successful claude-review check run with head_sha equal to the requested commit. No full iOS suite was required for this review; the requested macOS and focused validations passed locally.

Checkout scope

The requested worktree was clean before and after validation. Generated build/test caches are ignored. The primary dirty checkout was not inspected or changed.