transit branch T-1803/bugfix-update-status-intent-not-found-hint-omits-requested-display-id commits 3 files 4 touched lines +151 / -8

Pre-push review: T-1803 display-ID not-found hint

Exact PR #214 head 0f66836303b2fa1566605e7c4c8a171c27e126c0 reviewed against base 0f3c64b612cef2b62c73a121027323aeebbff53d.

At a glance

  • Behavior: Missing display IDs now return No task with displayId N; UUID misses retain the generic hint.
  • Safety: Malformed/missing/duplicate identifier paths retain their established error classifications and mutation ordering.
  • Evidence: make lint, make test-quick, git diff --check, and fresh CI run 30776295144 / job 91572336674 passed.
  • Baseline: The exact-head report records three unrelated full-iOS UI failures; no T-1803 UI files changed.

Verdict

Ready to push / merge

No actionable correctness, security, performance, maintainability, or test-contract findings were identified. The rebased patch is semantically equivalent to the previously reviewed patch, the requested not-found and malformed-input contracts are preserved, and all available required checks pass.

Commits

Three-level explanation

What changed

The shared App Intent task resolver now distinguishes a real missing task from malformed input. When a valid numeric displayId is absent, the error says which ID was requested. UUID-based misses keep the existing generic hint.

Why it matters

Automation callers can identify the missing task directly, while invalid requests and ambiguous store data are not mislabeled as ordinary not-found errors.

Architecture

TaskService.resolveTask(from:) performs typed lookup and throws typed service errors. IntentHelpers.resolveTask maps those errors to the stable IntentError JSON contract shared by UpdateStatusIntent and UpdateTaskIntent.

Trade-off

The service error remains presentation-neutral; the mapper uses the original validated JSON only to construct the display-ID-specific hint. Catch ordering keeps malformed and duplicate cases ahead of the new not-found branch.

Edge cases

The dictionary resolver checks key presence before conversion, so malformed displayId or taskId values cannot fall through to another identifier. findByDisplayID still distinguishes zero matches from duplicate matches. The intent preflight keeps absent identifiers as INVALID_INPUT, and resolution completes before status mutation.

Rebase audit

The range diff maps all three prior commits to the three current commits. The normalized code/test/report patch has stable patch ID add997d83a1283ad5694f98abec08654005b56af; differences are unrelated base-branch changelog context.

Important changes — detailed

IntentHelpers: map genuine lookup misses to actionable hints

Transit/Transit/Intents/IntentHelpers.swift

Why it matters. Satisfies requirement 17.5 without changing the service-layer error contract or the existing UUID behavior.

What to look at. IntentHelpers.swift:157-185

Takeaway. Keep service errors presentation-neutral and use the already-validated request context at the intent boundary to build actionable automation-facing hints.
Rationale. The shared resolver already owns App Intent error translation and receives the original JSON, avoiding duplicated lookup logic in individual intents.

UpdateStatusIntentTests: pin complete error contracts

Transit/TransitTests/UpdateStatusIntentTests.swift

Why it matters. Protects both the exact display-ID fix and unchanged malformed, missing, UUID, and payload-shape behavior.

What to look at. UpdateStatusIntentTests.swift:47-194, 315-330

Takeaway. For JSON automation APIs, assert the complete stable error object rather than only its error code.
Rationale. Exact hints are part of the caller-facing contract and catch regressions that code-only assertions would miss.

Bugfix report and changelog: document the compatibility boundary

specs/bugfixes/update-status-intent-not-found-hint-omits-requested-display-id/report.md

Why it matters. Records the root cause, alternatives, affected shared call sites, regression coverage, and known full-iOS baseline evidence.

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

Takeaway. A bugfix report should distinguish the changed behavior from preserved adjacent contracts and record unrelated validation baselines explicitly.
Rationale. The report provides the audit trail used to verify that the shared-resolver change is scoped and that the simulator baseline is not attributed to this patch.

Key decisions

Handle the hint in the shared intent resolver.

This avoids changing TaskService.Error.taskNotFound for non-JSON callers and prevents UpdateStatusIntent and UpdateTaskIntent from drifting apart.

Echo only successfully parsed display IDs.

taskNotFoundHint uses parseIntValue, while malformed values are intercepted earlier as INVALID_INPUT. This prevents an invalid value from being presented as a confirmed missing task.

Retain the generic UUID miss hint.

UUID misses do not have a display ID to echo, so the established generic identifier guidance remains unchanged.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5411aeb..9f65d53 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -1,88 +1,89 @@ # Changelog  All notable changes to this project will be documented in this file.  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +- 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 - MCP `create_task` and JSON `CreateTaskIntent` now reject present non-object `metadata` values (string, array, number, boolean, or null) with field-specific errors before task insertion, while omitted metadata and T-723's tolerant handling of values inside valid objects remain unchanged (T-1991). - T-2036: `QueryTasksIntent` now treats empty `completionDate` and `lastStatusChangeDate` objects as valid no-op filters, including when both are present. Raw-object emptiness is preserved before Codable decoding, while nested nulls, malformed non-empty objects, strict dates, reversed ranges, open bounds, and relative precedence remain covered by regression tests. - MCP request validation now rejects explicit JSON `id: null` as JSON-RPC `-32600 Invalid Request` under protocol `2025-03-26`, while preserving omitted-ID notifications and valid string/integer IDs across single and batch requests (T-1863). - MCP `tools/call` now returns JSON-RPC `-32602 Invalid Params` for unknown tool names and maintenance tools disabled in Settings (T-1883), while retaining useful messages, omitting disabled tools from `tools/list`, and preserving `-32601 Method Not Found` for unsupported top-level methods. Handler and HTTP-route regressions cover the classification boundaries. - T-1819: `QueryTasksIntent` now rejects reversed absolute `from`/`to` date ranges for both `completionDate` and `lastStatusChangeDate` with `INVALID_INPUT`, matching visual `FindTasksIntent` behavior. One-sided/open bounds, equal same-day inclusive ranges, relative-filter precedence, strict T-1799 calendar-date parsing, and current calendar/time-zone semantics remain unchanged; cross-surface regressions cover both date fields. - 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. - T-1802: Compact-width portrait dashboard layouts now always use the segmented single-column view, including widths at and above 400 points. Landscape, iPad, and Mac continue to use geometry-based column adaptation through a focused layout-selection helper and regression coverage. - MCP `update_task_status` now returns the exact comment created by its atomic status-plus-comment mutation (T-1823). `TaskService.updateStatus` propagates the created `Comment` to the handler for direct serialization instead of refetching every task comment and taking the last `creationDate`; a future-dated existing or concurrently imported comment can no longer replace the mutation result. If the atomic save fails, the newly inserted comment is deleted before the task state is rolled back so a later save cannot resurrect it. Regressions verify exact UUID propagation, future-clock-skew behavior, and service/MCP failure recovery. - Cross-device task and milestone promotion now re-probes committed SwiftData state after display-ID allocation and skips assignment when a peer has already supplied a permanent ID (T-2020). Missing or unreadable records fail closed, save-failure recovery preserves peer-merged values, and deterministic two-context tests pin both record types. Because SwiftData has no field-level compare-and-set, this closes the peer-merge-during-await window but cannot prevent two devices whose final local probes both precede either CloudKit merge from racing; the limitation and owner-reservation alternative are documented.  - MCP `initialize` now requires object params with string `protocolVersion`, object `capabilities`, and typed `clientInfo` identity fields (T-1778). Missing or malformed method parameters return JSON-RPC `-32602 Invalid Params`. JSON-RPC request decoding now rejects scalar/null top-level `params` server-wide with `-32600 Invalid Request`. Supported `2025-03-26` requests are echoed and unsupported versions receive the server's latest advertised supported version. Focused handshake tests cover absent params/fields, wrong types, malformed client information, request-shape errors, and both negotiation paths. - MCP server start, stop, and same-port restart now run through one async desired-state lifecycle coordinator (T-1826). Each active listener owns its Hummingbird `ServiceGroup` and run task; teardown explicitly triggers graceful shutdown and awaits the group before rebinding, rather than assuming cancellation completion releases the socket. Graceful shutdown is time-bounded so a stalled request cannot wedge the coordinator, and the group installs no SIGTERM/SIGINT traps (`runService()`'s default) because those change the process's signal disposition permanently. Requests arriving during teardown replace the pending target so a burst converges on the latest state, Settings uses an explicit restart operation, and live loopback regressions cover stop, 20 serialized same-port repetitions, different-port restart, rapid off/on, an occupied port surfacing the bind failure, an invalid port releasing the running listener, bounded shutdown escalation, empty signal configuration, and reusable bind-probe behavior. - Already-cancelled task and milestone creates now stop before an uncontended display-ID allocation, and cancellation during a successful non-cooperative allocation is re-checked before SwiftData insertion (T-1765). `AllocationGate` checks cancellation once it holds the lock and before running the allocation body, while both create services guard their persistence boundary; existing selective `insertOrDelete` save-failure cleanup is unchanged. The gate check is shared by every allocator caller, so a cancelled display-ID promotion or duplicate-cleanup pass now stops at the current record and retries on the next pass. If the counter advance already succeeded, cancellation may leave a harmless gap in the display-ID sequence rather than persisting a ghost record. Four deterministic regressions cover the new timing windows across tasks and milestones, and the two retained queued-cancellation cases now prove that their contender entered and was removed from the gate queue. - The MCP Streamable HTTP endpoint now accepts non-empty JSON-RPC request/notification batches for advertised protocol `2025-03-26` (T-1834). It decodes each member independently, dispatches requests and notifications sequentially, omits notification responses, preserves response-array semantics, returns HTTP 202 with no body for all-notification batches, emits per-member `-32600` errors for invalid entries, handles empty batches as one invalid-request object, and rejects lifecycle-invalid batched `initialize` calls. Route-level regression tests cover each behavior and unchanged single-request responses. - MCP `create_task`, `CreateTaskIntent`, and `AddTaskSheet.persist` now create a task plus optional milestone atomically in one `TaskService` save (T-1768). The relationship is validated and attached before insertion; failed saves delete the pending aggregate instead of relying on a second, fallible compensating deletion. Surface-level failure regressions verify no task remains or is resurrected by a later save. `CreateTaskIntent` maps the merged save's failures per error kind, so a storage failure that was previously reported as `INTERNAL_ERROR` by the separate milestone-assignment step still reports `INTERNAL_ERROR` rather than collapsing into `INVALID_INPUT`; `milestoneProjectMismatch` from the service boundary surfaces as `MILESTONE_PROJECT_MISMATCH`. - Duplicate cleanup now re-probes a task or milestone loser's committed display ID after asynchronous allocation and immediately before mutation (T-2019). Peer changes that land during allocation are preserved and reported as `stale-id`; task cleanup no longer emits a false audit comment for an overwritten peer repair. Deterministic gated regressions cover both record types and the task audit path. - Editor conflict choices now protect against unseen concurrent changes across task, project, and milestone editors (T-1935). **Use Updated Values** rebases the full draft onto current live values while preserving only genuine non-conflicting user edits, and **Keep My Changes** authorizes only the exact conflict field values shown; either action re-alerts when the conflict snapshot changes. - SwiftData test fixtures now retain their backing `ModelContainer` for the full test lifetime (T-2003). `TestModelContainer` is a container-owning fixture instead of a bare-context factory, 207 context-acquisition call sites across 92 test files were migrated, and bespoke context helpers in task-entity, task-creation-result, and report tests now delegate to the shared fixture. An escaped-context lifetime regression plus an executable ownership guard with positive/negative fixtures prevent raw container/context factories from recurring.  - Cross-device milestone name conflicts no longer make name-based operations target an arbitrary record (T-1938). `MilestoneService.findByName` now throws on multiple project-scoped matches; all MCP and App Intent callers report the ambiguity (`AMBIGUOUS_MILESTONE` for intents). Launch/foreground/connectivity maintenance deterministically keeps the oldest milestone name and renames other UUID-distinct records with UUID-derived suffixes, preserving records and task assignments. Cross-context tests simulate CloudKit imports and verify ambiguity reporting, reconciliation, idempotence, and data preservation. - Task mutation surfaces no longer hide duplicate task display IDs behind a not-found error (T-1837). `TaskService.findByDisplayID` already threw `duplicateDisplayID` when several tasks share a `permanentDisplayId` (possible under CloudKit, which cannot enforce uniqueness), but MCP `update_task_status` / `update_task` / `add_comment`, `UpdateStatusIntent`, `IntentHelpers.resolveTask`, and the visual `AddCommentIntent` all collapsed it into `TASK_NOT_FOUND` / "Provide either displayId or taskId", so operators could not tell missing data from store corruption needing display-ID maintenance. Duplicates now report `INTERNAL_ERROR` with the hint `Duplicate task identifier for displayId N` (App Intents — matching the milestone paths and the T-1097 `QueryTasksIntent` fix) or an `isError` result reading `Duplicate task identifier detected for displayId N` (MCP — matching the milestone tools). MCP `query_tasks` uses the same wording instead of leaking `Lookup failed: duplicateDisplayID`. No new error code was introduced. The three MCP call sites now share `resolveTaskArgument`, `UpdateStatusIntent` routes through `IntentHelpers.resolveTask` instead of its own copy of the mapping, and `VisualIntentError` gains a `duplicateIdentifier` case (code `INTERNAL_ERROR`). Regression coverage in `DuplicateTaskDisplayIDIntentTests` and `MCPDuplicateTaskDisplayIDTests`. - The MCP `add_comment` handler now distinguishes a missing required field from a present-but-non-string one for `content` and `authorName` (T-1579). Previously `guard let value = args[key] as? String, !value.isEmpty` collapsed numbers, booleans, arrays, and null into `Missing required argument: content` / `Missing required argument: authorName`, misleading callers into thinking they omitted a field they actually supplied with the wrong type. A present non-string value now returns the field-specific `content must be a string` / `authorName must be a string` before any task resolution or comment creation, matching the hardened pattern used by `update_task_status` after T-1205. Extracted a `requiredString(_:key:)` helper to keep `handleAddComment` within the cyclomatic-complexity limit. Regression coverage added in `MCPAddCommentTypeValidationTests` (numeric/boolean/array/null `content` and `authorName`). - `IntentHelpers.resolveMilestone` now rejects a present-but-non-string `name` identifier (number, boolean, null, array) with `INVALID_INPUT` and the field-specific hint `name must be a string`, instead of treating the failed `as? String` cast as an absent identifier and returning the generic `Provide displayId, milestoneId, or name with project` hint (T-1572). This only affects the `UpdateMilestoneIntent` name+project identifier path; no update fields are applied when the identifier is malformed. The MCP `update_milestone` tool is unaffected (it does not support name-based identification). Regression tests added to `MilestoneRenameTypeValidationTests`. - The MCP `update_milestone` handler and `UpdateMilestoneIntent` now support clearing a milestone description by passing an empty or whitespace-only `description` string, mirroring `update_task` and the milestone edit UI (T-1555). Previously a present `description` was applied verbatim, so `""`/whitespace persisted an empty string and the MCP response kept emitting a `description` key. Non-empty values are now trimmed before storage, and a cleared description sets `milestoneDescription` to nil (the MCP `milestoneToDict` serializer already omits the key when nil). For cross-surface parity, `UpdateMilestoneIntent`'s response now also emits `description` when set and omits it when cleared (previously it never emitted the key). T-343 had fixed only the edit-UI path. Cross-surface regression tests added in `MCPMilestoneClearDescriptionTests` and `UpdateMilestoneClearDescIntentTests`. - MCP `update_task` apply/save failure messages are now uniformly worded `"Update failed: \(error)"` (T-650 phase 4). Previously the handler emitted three separate templates (`"Failed to save: ..."`, `"Failed to set milestone: ..."`, `"Failed to clear milestone: ..."`); none of these are referenced by existing tests, but agents matching the old strings will see new wording. Validation, identifier-resolution, and milestone error messages are unchanged. - `QueryTasksIntentTests` no longer fails to compile after T-1146 added a required `milestoneService:` argument to `QueryTasksIntent.execute`; the missing argument is now supplied at the one stale call site so the test target builds. - Milestone status updates are now idempotent: re-sending the current status no longer rewrites `lastStatusChangeDate` and `completionDate`, so old terminal milestones no longer re-enter the current report window via `ReportLogic`'s `completionDate ?? lastStatusChangeDate` fallback. Applied across `MilestoneService.updateStatus`, `UpdateMilestoneIntent.applyUpdate`, and `MCPToolHandler.applyMilestoneUpdate` (T-923). - `DisplayIDMaintenanceService.reassignDuplicates` now surfaces a fetch failure as a counter-advance warning on both record types instead of silently no-oping the run with empty arrays. - `DisplayIDMaintenanceService` group processing no longer fabricates winner identity (`UUID()` / `""`) for the unreachable empty-group case; the invariant is asserted via `preconditionFailure` and the existing `RecordRef` is propagated. - `IntentHelpers.resolveMilestone` now rejects malformed `milestoneId` and `projectId` with `INVALID_INPUT` instead of silently falling back to name-based lookup (T-753) - Sync toggle in Settings now updates `SyncManager` runtime state immediately instead of only taking effect on next launch (T-699)  ### Changed  - **`update_task` response shape changed (T-650)**. The new `IntentHelpers.taskUpdateResponseDict` builder (used by both the MCP tool and `UpdateTaskIntent`) no longer emits `comments`, `lastStatusChangeDate`, or `completionDate`, and now emits `description` and `metadata` only when non-empty (previously `description` was present as `null` and `metadata` was absent). The new shape is fixed by AC 9.1; clients parsing those removed keys from `update_task` responses must instead call `query_tasks` to fetch them. Identifier-resolution and error envelopes are unchanged. - `DisplayIDMaintenanceService` init no longer takes `taskCounterStore`/`milestoneCounterStore`; the stores are read from each `DisplayIDAllocator.counterStore` (single source of truth). - `IntentHelpers.encodeAsJSONString(_:)` is the shared JSON encoding helper used by `MCPToolHandler` and the maintenance App Intents, replacing three per-site copies of the same `JSONEncoder()` + UTF-8 guard. - `MCPToolHandler` now derives the gated maintenance tool name set from `MCPToolDefinitions.maintenanceToolNames` so the gate stays in sync with the definitions list. - Service-level error messages in `DisplayIDMaintenanceService` use `error.localizedDescription` rather than `"\(error)"` interpolation for more actionable output in the result envelope. - Decision 11 in the duplicate-displayid-cleanup decision log records the choice to use `FetchDescriptor` for the stale-ID guard (instead of `ModelContext.refresh(_:mergeChanges:)`) since the service hands `RecordRef` value types across the scan/reassign boundary rather than `@Model` references.  ### Added  - Effective-priority invariant cross-surface regression test (T-1463, Verification phase). `EffectivePriorityInvariantTests` creates a legacy task with `priorityRawValue = ""` (set directly, bypassing the `priority` setter) and asserts it reads/serializes as `medium` and is matched by a `medium` priority filter on all five read surfaces — board `DashboardLogic`, `MCPQueryFilters.matches`, `IntentHelpers.taskToDict`, `QueryTasksIntent` `applyFilters`, and `IntentHelpers.taskUpdateResponseDict` — plus the accessor itself. This is the guard against any surface reading `priorityRawValue` directly instead of the computed `priority` accessor and silently breaking Req 1.4. - Task priority in the app UI (T-1463, UI phase). The kanban dashboard gains a `PriorityFilterMenu` (multi-select high/medium/low, displayed high→medium→low, ephemeral state that resets on launch) wired into `DashboardView`'s filter row and `DashboardLogic` predicate, matching on the computed `task.priority` so legacy/unset tasks read as medium. Task cards show a new `PriorityIndicator` glyph for high/low only (medium suppressed, read from `TaskPriority.glyphSymbol`). The create sheet (`AddTaskSheet`) and edit screen (`TaskEditView`) get priority pickers (default medium / current value, both iOS and macOS layouts), and `TaskDetailView` shows a priority row. Adds `TaskPriority.displayOrder` (`[.high, .medium, .low]`) for consistent picker/filter ordering. `DashboardView`'s filter inputs were bundled into a `FilterSelection` value type as part of this. - Task priority across the MCP server and App Intents (T-1463, Automation phase). New dedicated `INVALID_PRIORITY` error code (its own `IntentError` case + hint, shared by both surfaces). `TaskUpdateValidator.validatePriority` mirrors `validateType` and threads an optional, non-clearable priority through `ValidatedTaskUpdate` (Decision 8). MCP `create_task` parses/defaults priority (invalid → error, no task created — Req 5.5) and echoes it; `query_tasks` filters by a multi-value `priority` array (mirrors `status`) via `MCPQueryFilters.priorities`; schemas updated in `MCPToolDefinitions`. App Intents: `CreateTaskIntent` validates optional-with-default priority (absent → medium, invalid → `INVALID_PRIORITY`) and echoes it in the response; `QueryTasksIntent` gains a scalar `priority` filter; `UpdateTaskIntent` sets priority via the validator. `IntentHelpers.taskToDict`/`taskUpdateResponseDict` (and the milestone task sub-dicts) serialize `task.priority.rawValue` — always the computed accessor, never `priorityRawValue`, upholding the effective-priority invariant (Req 1.4). The `createTaskIntentJsonContractRemainsCompatible` contract test was updated for the new `priority` key (existing keys unchanged, so callers ignoring new keys stay compatible). - Task priority foundation (T-1463, Foundation phase): new `TaskPriority` enum (low/medium/high) mirroring `TaskType` — `tintColor` for all three, `glyphSymbol` (`nil` only for medium, the single source of truth for "no card glyph"), and `accessibilityLabel`. `TransitTask` gains a CloudKit-safe `priorityRawValue` stored property (default `medium`, same shape as `statusRawValue`/`typeRawValue`) and a computed `priority` accessor that reads any absent/empty/unrecognized raw value as `.medium` (the effective-priority invariant, Req 1.4 — no migration needed). `TaskService.createTask` (both overloads) accepts a defaulted `priority`, and `updateTask` accepts an optional `priority` where omitting it leaves the value unchanged (Decision 8, non-clearable). Automation and UI wiring follow in later phases. - Spec for the task priority feature (T-1463): requirements, design, decision log, and a 20-task implementation plan written to `specs/task-priority/`, with `specs/OVERVIEW.md` updated. Plans a low/medium/high priority field (default medium) shown as a board-card glyph for high/low (medium suppressed), a board filter, in-app create/edit/detail editing, and MCP + App Intent read/set/filter support — using a default-value + computed-accessor backfill (no migration) and a dedicated `INVALID_PRIORITY` error code. Planning only; not yet implemented. - The dashboard now shows SwiftUI's `ContentUnavailableView.search` empty state ("No Results for '<query>'") when text search is the only active filter and no task matches (T-198). Project/type/milestone filters keep the existing "No matching tasks. Clear filters" message, and an empty database still shows "No tasks yet". The search bar stays visible so the query can be edited or cleared. The choice of empty state is a pure `DashboardLogic.emptyStateKind` function (empty-DB > search-only > generic-filter precedence) with unit-test coverage in `DashboardSearchTests` and UI coverage in `SearchEmptyStateUITests`. - Spec for adding a `ContentUnavailableView.search` empty state to the dashboard when text search is the only active filter and yields no matches (T-198). Includes a smolspec, decision log (search-only-vs-generic-filter precedence, empty-database precedence, and extracting empty-state selection into a pure `DashboardLogic.emptyStateKind` function for unit-testability), and a 4-task implementation plan with distributed testing. Deferred follow-up from the text-filter feature. - `docs/agent-notes/mcp-server.md` Tools Exposed table now describes the extended `update_task` (name / description / type / metadata + milestone) with a gotcha noting the omit-vs-clear distinction and the shared validator + response-builder pipeline both surfaces use (T-650 phase 7). - `UpdateTaskAllFieldsParityTests` (T-650 phase 6): 10 cross-surface parity cases (each new field individually, combined multi-field, no-op echo, milestone assign and clear) call both `MCPToolHandler.handleUpdateTask` and `UpdateTaskIntent.execute` against a shared task fixture and assert byte-equal JSON responses. Verifies AC 8.1 in practice. All cases passed on the first run with zero production diffs — both surfaces route through `TaskUpdateValidator` + `IntentHelpers.taskUpdateResponseDict`, so structural parity is a property of the architecture rather than an emergent coincidence. - `UpdateTaskIntent` (App Intent) now accepts the same `name` / `description` / `type` / `metadata` updates as the MCP tool (T-650 phase 5). `execute()` rewritten as a thin orchestrator over `TaskUpdateValidator` + `IntentHelpers.taskUpdateResponseDict`, mirroring `MCPToolHandler.handleUpdateTask` structurally so success responses are byte-equivalent across surfaces (AC 8.1). The old `applyMilestoneChange` and `buildResponse` helpers are deleted. The `@Parameter` description enumerates every supported field including the empty-string-clears and `{}`-clears semantics. A preflight identifier guard returns `INVALID_INPUT` ("Provide either displayId or taskId") for payloads with no `taskId`/`displayId` — previously these landed as a misleading `TASK_NOT_FOUND` from the downstream resolver, including the pre-existing failing test `noTaskIdentifierReturnsInvalidInput`, which now passes. - MCP `update_task` now accepts name, description (empty/whitespace clears), type, and metadata (`{}` clears) alongside the existing milestone fields (T-650 phase 4). `MCPToolHandler.handleUpdateTask` rewritten as a thin orchestrator over `TaskUpdateValidator` + `IntentHelpers.taskUpdateResponseDict`. The two-stage do/catch (apply with explicit `taskService.rollback()`; save relying on `TaskService.save()`'s built-in `safeRollback`) keeps the task untouched when any field validation, apply, or save step fails — atomic per AC 5.1. Schema (`MCPToolDefinitions.updateTask`) extended with name/description/type/metadata properties; description and metadata prose include `"clear"` so clients know the clear semantics. Identifier-resolution preamble (T-634 / T-808) and milestone error literals are byte-equivalent to the pre-rewrite handler so existing tests pass unchanged. - `IntentHelpers.taskUpdateResponseDict` (T-650 phase 3): shared response builder for `update_task` across MCP and App Intent surfaces. Always emits `taskId`, `name`, `type`, `status`; emits `displayId`, `projectId`, `projectName`, `description`, `milestone` only when non-nil; emits `metadata` only when non-empty. Excludes `comments` and the date fields. 13 tests cover every conditional clause of AC 9.1. - `TaskUpdateValidator.validate` / `apply` / `strictStringMetadata` implementations (T-650 phase 2). `validate` parses raw MCP/Intent argument dicts into a `ValidatedTaskUpdate` covering name (trim + non-empty), description (trim, empty/whitespace clears), type (lowercase enum), metadata (strict-string, `{}` clears), and milestone (precedence `clearMilestone` → `milestoneId` → `milestoneDisplayId` → `milestone`). Milestone error literals are byte-identical to the existing MCP handler so Phase 3 wiring is drop-in. `apply` walks fields in deterministic order with `save: false`, throwing service-layer errors only — callers handle rollback via `TaskService.rollback()`. `TaskUpdateValidatorTests` covers 33 scenarios across all acceptance criteria 1–7. - `TaskUpdateValidator` scaffolding (T-650 phase 1): new `Transit/Transit/Intents/TaskUpdateValidator.swift` declares the `FieldChange<T>`, `MilestoneAction`, `ValidatedTaskUpdate`, and `TaskUpdateValidationError` value types plus stub `validate`/`apply`/`strictStringMetadata` signatures so subsequent phases can write tests against the contract. `TaskService.rollback()` wraps `modelContext.safeRollback()` for handler-level undo after a mid-update throw. - Spec for extending `update_task` MCP tool and `UpdateTaskIntent` to update name, description, type, and metadata atomically alongside the existing milestone fields (T-650). Includes requirements, design (shared `TaskUpdateValidator` with `FieldChange<T>`-based update model, new `IntentHelpers.taskUpdateResponseDict`, `TaskService.rollback` for apply-failure rollback), decision log, and a 12-task implementation plan across 7 phases. - Test for AC 8.1: an allocation failure on one duplicate group does not abort subsequent groups; the loser loop breaks for the failing group only and the next group still reassigns. - `DisplayIDMaintenanceService` is now constructed cross-platform in `TransitApp`, registered via `AppDependencyManager` for App Intents, and exposed via `.environment` on the root `NavigationStack` and `withCoreEnvironments` so `DataMaintenanceView` resolves it on iOS, the macOS Settings window, and the macOS Task Detail window. - `ScanDuplicateDisplayIDsIntent` and `ReassignDuplicateDisplayIDsIntent` App Intents for running duplicate cleanup from Shortcuts. Both reuse the same `JSONEncoder` path as the MCP tools so payloads are byte-equal across surfaces; service errors land inside the JSON envelope rather than thrown. - `DataMaintenanceView` in Settings — scan → confirm (destructive alert) → reassign → result flow, available on iOS and macOS. macOS Settings adds a "Data Maintenance" sidebar category and an "Expose maintenance tools" Toggle in the MCP Server section. - `NavigationDestination.dataMaintenance` case wires the new view into both the iOS settings stack and the macOS settings window. - UI test for the Data Maintenance golden path with seeded duplicates via a new `UITestScenario.duplicateDisplayIds` case; matched accessibility identifiers `dataMaintenance.scanButton/.reassignButton/.confirmButton/.resultList`. - MCP server exposes `scan_duplicate_display_ids` and `reassign_duplicate_display_ids` tools, gated behind a new `MCPSettings.maintenanceToolsEnabled` toggle (UserDefaults `mcpMaintenanceToolsEnabled`, default off). When the toggle is off, both tools are excluded from `tools/list` and `tools/call` returns JSON-RPC `methodNotFound` with a distinct "Tool '<name>' is disabled. Enable maintenance tools in Transit Settings." message. - `MCPToolDefinitions.tools(includingMaintenance:)` helper splits core tools from maintenance tools so the maintenance tools no longer cost MCP context for every agent session. - `DisplayIDAllocator.counterStore` accessor exposes the underlying `CounterStore` so callers needing direct counter access (e.g. `DisplayIDMaintenanceService`'s counter-advance fence) can share the allocator's store. - `DisplayIDMaintenanceService` with `scanDuplicates` and `reassignDuplicates` for repairing tasks/milestones sharing a `permanentDisplayId`. Counter advance happens before loser allocation; reassigned tasks receive a "Transit Maintenance" audit comment recording the old and new display IDs and the date. - `DisplayIDMaintenanceTypes`: `DuplicateReport`, `ReassignmentResult`, `FailureCode`, and Codable encoders matching the JSON shape shared by MCP and App Intents. - `CounterStore.advanceCounter(toAtLeast:retryLimit:)` extension with default implementation that retries on conflict and short-circuits when a racing writer has already moved the counter past the target. - Spec for duplicate display ID cleanup: requirements, design, decision log, and 18-task implementation plan in `specs/duplicate-displayid-cleanup/` - Task detail view shows creation date in secondary style on both iOS and macOS (T-755) - macOS: Task detail opens in a dedicated window instead of a sheet, with share and edit buttons in the toolbar (T-35)
Transit/Transit/Intents/IntentHelpers.swift Modified +12 / -0
diff --git a/Transit/Transit/Intents/IntentHelpers.swift b/Transit/Transit/Intents/IntentHelpers.swiftindex 300b357..81df9ae 100644--- a/Transit/Transit/Intents/IntentHelpers.swift+++ b/Transit/Transit/Intents/IntentHelpers.swift@@ -87,167 +87,179 @@ nonisolated enum IntentHelpers {         guard let data = try? JSONSerialization.data(withJSONObject: array),               let string = String(data: data, encoding: .utf8) else {             return "[]"         }         return string     }      enum EncodingError: Swift.Error { case utf8 }      /// Encodes an `Encodable` value as a UTF-8 JSON string. Used by both the MCP     /// dispatch handler and the maintenance Intents so they share a byte-equal     /// encoding for the same input.     static func encodeAsJSONString(_ value: some Encodable) throws -> String {         let data = try JSONEncoder().encode(value)         guard let text = String(data: data, encoding: .utf8) else {             throw EncodingError.utf8         }         return text     }      /// The JSON error body every mutating App Intent returns while Transit is running on the     /// in-memory fallback container, or `nil` when writes are durable.     ///     /// INTERNAL_ERROR is the documented code for "the request was well-formed but Transit could     /// not carry it out", which is exactly this case — the caller did nothing wrong, the store     /// did. Reusing it keeps the error vocabulary unchanged for existing CLI callers [T-1836].     @MainActor     static func fallbackStorageErrorJSON(_ persistence: PersistenceAvailability) -> String? {         guard persistence.isFallbackStorageActive else { return nil }         return IntentError.internalError(hint: PersistenceAvailability.unavailableHint).json     }      /// Translates ProjectLookupError to IntentError.     static func mapProjectLookupError(_ error: ProjectLookupError) -> IntentError {         switch error {         case .notFound(let hint):             .projectNotFound(hint: hint)         case .ambiguous(let hint):             .ambiguousProject(hint: hint)         case .storageFailure(let hint):             .internalError(hint: hint)         case .noIdentifier:             .invalidInput(hint: "Either projectId or project name is required")         }     }      /// Translates MilestoneService.Error to IntentError.     static func mapMilestoneError(_ error: MilestoneService.Error) -> IntentError {         switch error {         case .invalidName:             .invalidInput(hint: "Milestone name cannot be empty")         case .milestoneNotFound:             .milestoneNotFound(hint: "No matching milestone found")         case .duplicateName:             .duplicateMilestoneName(hint: "A milestone with this name already exists in the project")         case .ambiguousName:             .ambiguousMilestone(hint: "Multiple milestones with this name exist in the project")         case .duplicateDisplayID:             .internalError(hint: "A duplicate milestone identifier was detected")         case .projectRequired:             .invalidInput(hint: "Task must belong to a project before assigning a milestone")         case .projectMismatch:             .milestoneProjectMismatch(hint: "Milestone and task must belong to the same project")         }     }      /// Resolves a task from JSON containing displayId or taskId, mapping errors to `IntentError`.     /// A present-but-malformed identifier surfaces as a field-specific INVALID_INPUT     /// instead of being silently swallowed by a TASK_NOT_FOUND fallback. [T-808]     @MainActor     static func resolveTask(         from json: [String: Any],         taskService: TaskService     ) -> Result<TransitTask, IntentError> {         do {             return .success(try taskService.resolveTask(from: json))         } catch TaskService.Error.invalidIdentifier(let field) {             return .failure(.invalidInput(hint: invalidIdentifierHint(for: field)))         } catch TaskService.Error.duplicateDisplayID {             return .failure(.internalError(hint: duplicateTaskIdentifierHint(from: json)))+        } catch TaskService.Error.taskNotFound {+            return .failure(.taskNotFound(hint: taskNotFoundHint(from: json)))         } catch {             return .failure(.taskNotFound(                 hint: "Provide either displayId (integer) or taskId (UUID)"             ))         }     } +    /// Builds an actionable hint for a genuine task lookup miss. Display-ID+    /// misses echo the supplied value as required by req 17.5; UUID misses+    /// retain the established generic identifier hint.+    static func taskNotFoundHint(from json: [String: Any]) -> String {+        if let displayId = parseIntValue(json["displayId"]) {+            return "No task with displayId \(displayId)"+        }+        return "Provide either displayId (integer) or taskId (UUID)"+    }+     /// Hint for a `displayId` matched by more than one task. CloudKit cannot enforce     /// unique display IDs, so the store — not the request — is at fault, and the fix     /// is display-ID maintenance rather than a corrected identifier. Reported as     /// INTERNAL_ERROR to match `mapMilestoneError` and `QueryTasksIntent`. [T-1097, T-1837]     static func duplicateTaskIdentifierHint(from json: [String: Any]) -> String {         guard let displayId = parseIntValue(json["displayId"]) else {             return "A duplicate task identifier was detected"         }         return "Duplicate task identifier for displayId \(displayId)"     }      /// Field-specific hint for an `invalidIdentifier` rejection. [T-808]     static func invalidIdentifierHint(for field: String) -> String {         switch field {         case "displayId":             return "displayId must be an integer"         case "taskId":             return "taskId must be a valid UUID string"         default:             return "\(field) is not a valid identifier"         }     }      /// Validates that a JSON field, if present, is a valid UUID string. Returns the parsed UUID,     /// nil when absent, or `.failure` when present but malformed. [T-753]     static func validateUUIDField(         _ key: String, in json: [String: Any]     ) -> Result<UUID?, IntentError> {         guard let value = json[key] else { return .success(nil) }         guard let str = value as? String, let uuid = UUID(uuidString: str) else {             return .failure(.invalidInput(hint: "\(key) must be a valid UUID"))         }         return .success(uuid)     }      /// Resolves a milestone from JSON containing displayId or milestoneId.     @MainActor     static func resolveMilestone(         from json: [String: Any],         milestoneService: MilestoneService,         projectService: ProjectService     ) -> Result<Milestone, IntentError> {         if json["displayId"] != nil {             return resolveMilestoneByDisplayId(json, milestoneService: milestoneService)         } else if json["milestoneId"] != nil {             return resolveMilestoneById(json, milestoneService: milestoneService)         } else if let rawName = json["name"] {             // A present-but-non-string "name" must be rejected here; otherwise `as? String`             // swallows it and the caller gets the generic "Provide ... or name" hint. [T-1572]             guard let name = rawName as? String else {                 return .failure(.invalidInput(hint: "name must be a string"))             }             return resolveMilestoneByName(                 name, json: json,                 milestoneService: milestoneService, projectService: projectService             )         }         return .failure(.invalidInput(hint: "Provide displayId, milestoneId, or name with project"))     }      /// Resolves a milestone by its integer display ID.     @MainActor     private static func resolveMilestoneByDisplayId(         _ json: [String: Any], milestoneService: MilestoneService     ) -> Result<Milestone, IntentError> {         guard let displayId = parseIntValue(json["displayId"]) else {             return .failure(.invalidInput(hint: "displayId must be an integer"))         }         do {             return .success(try milestoneService.findByDisplayID(displayId))         } catch MilestoneService.Error.duplicateDisplayID {             return .failure(.internalError(hint: "Duplicate milestone identifier for displayId \(displayId)"))         } catch {             return .failure(.milestoneNotFound(hint: "No milestone with displayId \(displayId)"))         }     }      /// Resolves a milestone by its UUID identifier. Rejects non-UUID values with INVALID_INPUT. [T-753]     @MainActor     private static func resolveMilestoneById(
Transit/TransitTests/UpdateStatusIntentTests.swift Modified +49 / -8
diff --git a/Transit/TransitTests/UpdateStatusIntentTests.swift b/Transit/TransitTests/UpdateStatusIntentTests.swiftindex c9ec43f..56f1237 100644--- a/Transit/TransitTests/UpdateStatusIntentTests.swift+++ b/Transit/TransitTests/UpdateStatusIntentTests.swift@@ -1,310 +1,351 @@ import Foundation import SwiftData import Testing @testable import Transit  @MainActor @Suite(.serialized) struct UpdateStatusIntentTests {      // MARK: - Helpers      private func makeService() throws -> (TaskService, ModelContext) {         let testContainer = try TestModelContainer()         let context = testContainer.context         let store = InMemoryCounterStore()         let allocator = DisplayIDAllocator(store: store)         let service = TaskService(modelContext: context, displayIDAllocator: allocator)         return (service, context)     }      private func makeProject(in context: ModelContext) -> Project {         let project = Project(name: "Test Project", description: "A test project", gitRepo: nil, colorHex: "#FF0000")         context.insert(project)         return project     }      /// Creates a task with a known permanent display ID for testing.     private func makeTask(         in context: ModelContext,         project: Project,         displayId: Int,         status: TaskStatus = .idea     ) -> TransitTask {         let task = TransitTask(name: "Test Task", type: .feature, project: project, displayID: .permanent(displayId))         StatusEngine.initializeNewTask(task)         if status != .idea {             StatusEngine.applyTransition(task: task, to: status)         }         context.insert(task)         return task     }      private func parseJSON(_ string: String) throws -> [String: Any] {         let data = try #require(string.data(using: .utf8))         return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])     } +    private func expectStableErrorPayload(+        _ result: String,+        error expectedError: String,+        hint expectedHint: String+    ) throws {+        let parsed = try parseJSON(result)+        #expect(Set(parsed.keys) == Set(["error", "hint"]))+        #expect(parsed["error"] as? String == expectedError && parsed["hint"] as? String == expectedHint)+    }+     // MARK: - Success Cases      @Test func validUpdateReturnsPreviousAndNewStatus() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         makeTask(in: context, project: project, displayId: 42)          let input = """         {"displayId":42,"status":"planning"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["displayId"] as? Int == 42)         #expect(parsed["previousStatus"] as? String == "idea")         #expect(parsed["status"] as? String == "planning")     }      @Test func updateToTerminalStatusWorks() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         let task = makeTask(in: context, project: project, displayId: 10, status: .inProgress)          let input = """         {"displayId":10,"status":"done"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["previousStatus"] as? String == "in-progress")         #expect(parsed["status"] as? String == "done")         #expect(task.completionDate != nil)     }      @Test func noOpUpdatePreservesTerminalTimestamps() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         let task = makeTask(in: context, project: project, displayId: 11, status: .done)          let originalStatusChangeDate = Date(timeIntervalSince1970: 2_222)         let originalCompletionDate = Date(timeIntervalSince1970: 2_111)         task.lastStatusChangeDate = originalStatusChangeDate         task.completionDate = originalCompletionDate          let input = """         {"displayId":11,"status":"done"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["previousStatus"] as? String == "done")         #expect(parsed["status"] as? String == "done")         #expect(task.lastStatusChangeDate == originalStatusChangeDate)         #expect(task.completionDate == originalCompletionDate)     }      // MARK: - Error Cases      @Test func unknownDisplayIdReturnsTaskNotFound() throws {         let (taskService, _) = try makeService()          let input = """         {"displayId":999,"status":"planning"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService) -        let parsed = try parseJSON(result)-        #expect(parsed["error"] as? String == "TASK_NOT_FOUND")+        try expectStableErrorPayload(+            result,+            error: "TASK_NOT_FOUND",+            hint: "No task with displayId 999"+        )     }      @Test func invalidStatusStringReturnsInvalidStatus() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         makeTask(in: context, project: project, displayId: 1)          let input = """         {"displayId":1,"status":"flying"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["error"] as? String == "INVALID_STATUS")     }      @Test func malformedJSONReturnsInvalidInput() throws {         let (taskService, _) = try makeService()          let result = UpdateStatusIntent.execute(input: "not json", taskService: taskService) -        let parsed = try parseJSON(result)-        #expect(parsed["error"] as? String == "INVALID_INPUT")+        try expectStableErrorPayload(+            result,+            error: "INVALID_INPUT",+            hint: "Expected valid JSON object"+        )     }      @Test func missingBothIdentifiersReturnsInvalidInput() throws {         let (taskService, _) = try makeService()          let input = """         {"status":"planning"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService) -        let parsed = try parseJSON(result)-        #expect(parsed["error"] as? String == "INVALID_INPUT")+        try expectStableErrorPayload(+            result,+            error: "INVALID_INPUT",+            hint: "Provide either displayId (integer) or taskId (UUID)"+        )+    }++    @Test func malformedDisplayIdReturnsStableInvalidInputPayloadWithoutFallback() throws {+        let (taskService, context) = try makeService()+        let project = makeProject(in: context)+        let task = makeTask(in: context, project: project, displayId: 42)++        let input = """+        {"displayId":"not-an-int","taskId":"\(task.id.uuidString)","status":"planning"}+        """++        let result = UpdateStatusIntent.execute(input: input, taskService: taskService)++        try expectStableErrorPayload(+            result,+            error: "INVALID_INPUT",+            hint: "displayId must be an integer"+        )+        #expect(task.statusRawValue == "idea")     }      @Test func missingStatusReturnsInvalidInput() throws {         let (taskService, _) = try makeService()          let input = """         {"displayId":1}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["error"] as? String == "INVALID_INPUT")     }      // MARK: - Non-String Status (T-1544)     //     // A present-but-non-string `status` must be rejected explicitly with     // INVALID_STATUS / "status must be a string" — consistent with the milestone     // status paths and enum filter validation — rather than being misreported as     // a missing field via `as? String` silently failing.      @Test func numericStatusReturnsInvalidStatus() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         makeTask(in: context, project: project, displayId: 1)          let input = """         {"displayId":1,"status":123}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["error"] as? String == "INVALID_STATUS")         #expect((parsed["hint"] as? String)?.contains("status must be a string") == true)     }      @Test func booleanStatusReturnsInvalidStatus() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         makeTask(in: context, project: project, displayId: 1)          let input = """         {"displayId":1,"status":true}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["error"] as? String == "INVALID_STATUS")         #expect((parsed["hint"] as? String)?.contains("status must be a string") == true)     }      @Test func arrayStatusReturnsInvalidStatus() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         makeTask(in: context, project: project, displayId: 1)          let input = """         {"displayId":1,"status":["done"]}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["error"] as? String == "INVALID_STATUS")         #expect((parsed["hint"] as? String)?.contains("status must be a string") == true)     }      @Test func nullStatusReturnsInvalidStatus() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         makeTask(in: context, project: project, displayId: 1)          let input = """         {"displayId":1,"status":null}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["error"] as? String == "INVALID_STATUS")         #expect((parsed["hint"] as? String)?.contains("status must be a string") == true)     }      /// A present-but-non-string status must be rejected before any mutation —     /// the task's status must be unchanged.     @Test func nonStringStatusDoesNotMutateTask() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         let task = makeTask(in: context, project: project, displayId: 1, status: .idea)          let input = """         {"displayId":1,"status":123}         """          _ = UpdateStatusIntent.execute(input: input, taskService: taskService)          #expect(task.statusRawValue == "idea")     }      // MARK: - taskId Lookup      @Test func updateViaTaskIdWorks() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         let task = makeTask(in: context, project: project, displayId: 50)          let input = """         {"taskId":"\(task.id.uuidString)","status":"planning"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed["taskId"] as? String == task.id.uuidString)         #expect(parsed["previousStatus"] as? String == "idea")         #expect(parsed["status"] as? String == "planning")     }      @Test func unknownTaskIdReturnsTaskNotFound() throws {         let (taskService, _) = try makeService()         let fakeId = UUID().uuidString          let input = """         {"taskId":"\(fakeId)","status":"planning"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService) -        let parsed = try parseJSON(result)-        #expect(parsed["error"] as? String == "TASK_NOT_FOUND")+        try expectStableErrorPayload(+            result,+            error: "TASK_NOT_FOUND",+            hint: "Provide either displayId (integer) or taskId (UUID)"+        )     }      // MARK: - Response Format      @Test func responseContainsAllRequiredFields() throws {         let (taskService, context) = try makeService()         let project = makeProject(in: context)         makeTask(in: context, project: project, displayId: 7)          let input = """         {"displayId":7,"status":"spec"}         """          let result = UpdateStatusIntent.execute(input: input, taskService: taskService)          let parsed = try parseJSON(result)         #expect(parsed.keys.contains("taskId"))         #expect(parsed.keys.contains("displayId"))         #expect(parsed.keys.contains("previousStatus"))         #expect(parsed.keys.contains("status"))     } }
specs/bugfixes/update-status-intent-not-found-hint-omits-requested-display-id/report.md Added +89 / -0
diff --git a/specs/bugfixes/update-status-intent-not-found-hint-omits-requested-display-id/report.md b/specs/bugfixes/update-status-intent-not-found-hint-omits-requested-display-id/report.mdnew file mode 100644index 0000000..709d08a--- /dev/null+++ b/specs/bugfixes/update-status-intent-not-found-hint-omits-requested-display-id/report.md@@ -0,0 +1,89 @@+# Bugfix Report: Update Status Intent Not-Found Display-ID Hint++**Date:** 2026-08-02+**Status:** Fixed+**Ticket:** T-1803++## Description of the Issue++`UpdateStatusIntent` returns `TASK_NOT_FOUND` for a valid but nonexistent `displayId`, but its hint says only `Provide either displayId (integer) or taskId (UUID)`. Requirement 17.5 requires the hint to echo the supplied display ID so callers can identify the missing task immediately.++**Reproduction steps:**+1. Call `UpdateStatusIntent` with `{"displayId":999,"status":"planning"}` when no task has display ID 999.+2. Parse the JSON error response.+3. Observe `TASK_NOT_FOUND` with a generic identifier hint that does not contain `999`.++**Impact:** Low. The operation does not mutate task state, but automation callers receive an incomplete diagnostic for a valid missing display ID.++## Investigation Summary++- **Symptoms examined:** A missing display-ID lookup returns `TASK_NOT_FOUND`, but the response hint omits the requested ID; UUID misses use the existing generic hint.+- **Code inspected:** `UpdateStatusIntent.swift`, `IntentHelpers.resolveTask`, `TaskService.resolveTask(from:)`, `UpdateTaskIntent.swift`, related identifier-validation tests, and MCP task-resolution wording.+- **Hypotheses tested:** The service correctly distinguishes malformed identifiers from lookup misses. The defect is in App Intent error-hint mapping, not in lookup, status validation, or mutation handling.++## Discovered Root Cause++`IntentHelpers.resolveTask` maps every non-validation lookup failure to the same generic `TASK_NOT_FOUND` hint. Because `TaskService.resolveTask(from:)` does not include which dictionary key was used in its `.taskNotFound` error, the shared mapper currently loses the supplied `displayId`.++**Defect type:** Error-message mapping logic error.++**Why it occurred:** The generic fallback was originally sufficient for both identifier forms, but requirement 17.5 made the display-ID miss response actionable by requiring the requested ID in the hint.++**Contributing factors:** The resolver is shared by `UpdateStatusIntent` and `UpdateTaskIntent`, while malformed identifier and duplicate-ID cases have separate mappings that must remain unchanged.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Intents/IntentHelpers.swift` — Added an explicit `TaskService.Error.taskNotFound` mapping. Genuine misses with a valid `displayId` now return `No task with displayId N`; UUID misses retain `Provide either displayId (integer) or taskId (UUID)`. Malformed identifiers and duplicate display IDs continue through their existing `INVALID_INPUT` and `INTERNAL_ERROR` mappings, and unexpected lookup failures retain the prior generic not-found mapping.+- `Transit/TransitTests/UpdateStatusIntentTests.swift` — Extended the display-ID not-found regression with an exact hint assertion and pinned the existing UUID not-found hint.+- `CHANGELOG.md` — Added the T-1803 entry under Unreleased/Fixed.++**Approach rationale:** Handle the dedicated not-found error in the shared resolver rather than changing `TaskService` or duplicating lookup logic in `UpdateStatusIntent`. The resolver already receives the original JSON, so it can echo only a successfully parsed display ID while preserving malformed-input validation, duplicate-ID classification, status validation, and mutation ordering.++**Alternatives considered:**+- Change `TaskService.Error.taskNotFound` to carry an identifier — rejected because the service is also used by non-JSON callers and the existing error contract does not need a presentation hint.+- Reintroduce an inline resolver branch in `UpdateStatusIntent` — rejected because T-1837 centralized this mapping specifically to prevent App Intent lookup behavior from drifting.+- Echo the raw `displayId` for every failure — rejected because malformed values must remain `INVALID_INPUT`, and storage/unknown failures must not be relabeled as a confirmed missing ID.++## Regression Test++**Test file:** `Transit/TransitTests/UpdateStatusIntentTests.swift`+**Test names:** `unknownDisplayIdReturnsTaskNotFound`, `unknownTaskIdReturnsTaskNotFound`++**What it verifies:** A missing display-ID lookup returns exactly `TASK_NOT_FOUND` with `No task with displayId 999`, while a missing UUID lookup retains the existing generic UUID-capable hint. Existing malformed-identifier, duplicate-ID, status, and atomic mutation tests remain unchanged.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Intents/IntentHelpers.swift` | Map display-ID misses to a hint containing the supplied ID while preserving UUID and other error mappings |+| `Transit/TransitTests/UpdateStatusIntentTests.swift` | Pin exact display-ID and UUID not-found hints |+| `CHANGELOG.md` | Document the T-1803 fix |+| `specs/bugfixes/update-status-intent-not-found-hint-omits-requested-display-id/report.md` | Record investigation, resolution, and verification |++## Verification++**Automated:**+- [x] Red-phase regression test fails before the fix (`unknownDisplayIdReturnsTaskNotFound`)+- [x] Regression assertions pass in `make test-quick`+- [x] `make test-quick` passes for the complete macOS `TransitTests` target, including existing malformed-identifier, duplicate-ID, status, and status/comment atomicity coverage+- [x] `make lint` passes with 0 violations in 319 files, including the SwiftData ownership guard+- [ ] `make test` — the iOS simulator suite built and ran, but three unrelated existing UI tests failed: `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`. No UI production or UI test files changed for T-1803; the Makefile pipeline also returned status 0 despite `xcodebuild` reporting these failures.++**Manual verification:**+- The exact display-ID assertion fails against the pre-fix generic hint and passes after the fix.+- `TaskService.resolveTask(from:)` still rejects malformed present identifiers before lookup, and duplicate display IDs still map to `INTERNAL_ERROR`.+- Resolution happens before `TaskService.updateStatus`, so not-found responses cannot mutate status; the existing macOS status/comment atomicity tests remain green.++## Prevention++**Recommendations to avoid similar bugs:**+- Build not-found hints from the identifier key that was actually supplied rather than using one fallback for every lookup form.+- Keep exact response-hint assertions beside error-code assertions for automation-facing intents.++## Related++- Requirement 17.5: `specs/transit-v1/requirements.md`+- Transit ticket: T-1803

Things to double-check

Post-rebase equivalence

git range-diff maps all three prior commits to the current three commits. The normalized code/test/report patch ID is identical; the full patch-ID difference is attributable to unrelated changelog context from the rebased base branch.

Full iOS baseline

The exact-head report records the known baseline failures in TransitUITests.testClearAll, TransitUITests.testEditViewPreservesTaskMilestone, and DataMaintenanceUITests.testDataMaintenanceGoldenPath. No T-1803 UI production or UI test files are in the diff.

GitHub review state

Fresh qualifying exact-head review comment was posted, and the post-comment GraphQL query reports zero review threads.