transit branch T-2036/bugfix-empty-task-query-date-filters-are-rejected commits 3 files 7 touched lines +315 / -56 threads 0

Pre-push review: T-2036/bugfix-empty-task-query-date-filters-are-rejected

Independent current-head review of PR #213 at exact head 0e90c0fb9bfea8912097210d9f914d795d323fea against base c23e9a423c760980cb63b5ece01db260e33fc222.

At a glance

  • Exact identity: local HEAD, local upstream, remote branch, and PR #213 head all resolve to 0e90c0fb9bfea8912097210d9f914d795d323fea; PR base is exactly c23e9a423c760980cb63b5ece01db260e33fc222.
  • Clean candidate: the dedicated T-2036 worktree is clean and exactly even with upstream; the merge-base is the requested PR base.
  • Independent review: the complete seven-file diff, all QueryTasksIntent callers, shared date-filter semantics, visual date-filter path, and regression coverage were inspected with no qualifying defect found.
  • Validation: post-rebase run_silent make lint, run_silent make test-quick, and git diff --check passed without source changes.
  • Remote gates: exact-head CI run 30775351532 / job 91569713134 completed successfully; PR #213 is open, mergeable, and has zero review threads and zero unresolved threads.
  • Semantic stability: stable patch ID is 1abed95ebd6512b0708f32397e048cf845edc175 for both the prior reviewed patch and this rebased patch when CHANGELOG.md is excluded; the T-1991 changelog entry is preserved byte-for-byte.

Verdict

Ready to push/merge

The independent current-head review found no qualifying defect. Exact local, upstream, and PR refs match; the candidate worktree is clean; the stable non-changelog patch ID matches the previously reviewed semantic patch; fresh exact-head claude-review CI is successful; GitHub reports zero review threads; and post-rebase make lint plus make test-quick pass. The known iOS simulator failures are documented baseline failures outside this intent/date-filter diff, not a branch-attributable defect. No source changes, push, or merge were performed.

Commits

Three-level explanation

What changed

Task queries now remember when completionDate or lastStatusChangeDate was literally {} before Swift's Codable decoder turns the empty object into missing optional values. Empty objects are valid no-op filters, while non-empty malformed objects remain errors.

Why it matters

Shortcut and JSON callers can use the documented no-op date-filter form without receiving INVALID_INPUT, and malformed input cannot silently broaden a query.

Key concepts

Raw JSON preflight preserves the one presence distinction that typed decoding erases; the existing typed date parser and matcher continue to handle actual filtering.

Architecture

parseInput validates raw object shape and nested nulls, records recognized empty date-filter keys, then decodes QueryFilters. A transient DateRangeFilter.isEmptyObject marker carries that fact into validation.

Behavior

validateDateFilters skips format validation only for marked empty objects. applyFilters still receives a nil parsed range, so the existing matcher treats the field as unfiltered while unrelated filters remain conjunctive. Every non-empty object still uses DateFilterHelpers.parseDateFilter.

Trade-off

The patch retains the established Codable model and parser instead of adding custom nested presence tracking or broadening acceptance for all unparsable objects.

Root cause and invariant

After decoding, {} and unknown-only non-empty objects both produce a DateRangeFilter with nil recognized members. Raw JSONSerialization is therefore the only boundary that can distinguish the valid no-op from malformed content. The patch stores only that minimal emptiness bit and excludes it from CodingKeys, preventing user input from spoofing it.

Edge cases

  • Top-level and recognized nested explicit nulls remain rejected before decoding.
  • Scalar and array date filters remain object-shape errors.
  • Unknown-only non-empty objects remain invalid because their marker is false and the parser returns nil.
  • Unknown keys beside a valid bound retain existing ignored-key behavior.
  • Empty objects produce nil ranges and do not alter matching; status and other filters still apply.

Impact

The production change is isolated to QueryTasksIntent parsing/validation and filter-type organization; shared date parsing and the visual intent path are unchanged.

Important changes — detailed

Preserve raw empty-object presence before Codable

Transit/Transit/Intents/QueryTasksIntent.swift

Why it matters. This is the correctness fix: it distinguishes a valid no-op date object from malformed non-empty content without weakening the existing date parser.

What to look at. QueryTasksIntent.swift:173-203

Takeaway. When Codable optional decoding erases presence, retain the smallest presence bit needed at the raw JSON boundary.
Rationale. The bug report identifies the existing raw JSON preflight as the presence-aware boundary and rejects custom Codable tracking as unnecessary.

Skip validation only for genuine empty filters

Transit/Transit/Intents/QueryTasksIntent.swift

Why it matters. The validation guard accepts the documented no-op while preserving field-specific INVALID_INPUT errors for malformed non-empty objects.

What to look at. QueryTasksIntent.swift:242-253

Takeaway. A no-op filter should reuse the existing nil-range behavior rather than treating every unparsable filter as harmless.
Rationale. This keeps unknown-only and malformed objects strict while allowing only raw objects proven empty to bypass format validation.

Add positive composition and negative-shape regressions

Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swift

Why it matters. The tests prove both date fields remain no-ops alongside status filtering and that unknown nested keys do not alter a valid bound.

What to look at. QueryTasksIntentEmptyDateFilterTests.swift:66-109

Takeaway. Presence-sensitive validation fixes need no-op composition tests paired with malformed/non-recognized-key tests for every affected field.
Rationale. The final regression commit extends coverage across both date fields and the interaction patterns most likely to conceal a broadening bug.

Retain strict malformed-input behavior

Transit/TransitTests/QueryTasksIntentNullFilterTests.swift

Why it matters. The negative regressions demonstrate that accepting {} did not loosen nested null, shape, or unknown-only validation.

What to look at. QueryTasksIntentNullFilterTests.swift:102-184

Takeaway. Test a valid empty object beside explicit-null and unknown-only objects because all can decode to optional nil values.
Rationale. The T-1644 presence contract is explicitly retained while adding the T-2036 emptiness distinction.

Key decisions

Preserve one emptiness bit instead of custom Codable presence tracking

The report states that raw JSON preflight already exists for top-level and nested null validation. Recording only whether each recognized date-filter object is empty preserves the current model and parser while solving the one lost distinction required by the contract.

Keep unknown nested keys ignored beside a valid recognized bound

This retains existing Codable behavior and avoids expanding the bugfix into a general unknown-key policy. The final regressions assert the behavior for both date fields.

(inferred — not stated by the author.)
Move filter value types into a focused source file

DateRangeFilter and QueryFilters move out of the large intent file so the transient marker and Codable models are isolated. This is a structural choice visible in the diff; no separate author rationale was stated.

(inferred — not stated by the author.)

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex b974125..912191d 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### 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 `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.
Transit/Transit/Intents/QueryTasksIntent+FilterTypes.swift Added +58 / -0
diff --git a/Transit/Transit/Intents/QueryTasksIntent+FilterTypes.swift b/Transit/Transit/Intents/QueryTasksIntent+FilterTypes.swiftnew file mode 100644index 0000000..80fdc2b--- /dev/null+++ b/Transit/Transit/Intents/QueryTasksIntent+FilterTypes.swift@@ -0,0 +1,58 @@+import Foundation++struct DateRangeFilter: Codable {+    var relative: String?+    var from: String?+    var toDate: String?+    /// Set from the raw JSON object before Codable erases whether it was empty.+    var isEmptyObject = false++    enum CodingKeys: String, CodingKey {+        case relative+        case from+        case toDate = "to"+    }++    init(relative: String? = nil, from: String? = nil, toDate: String? = nil) {+        self.relative = relative+        self.from = from+        self.toDate = toDate+    }+}++struct QueryFilters: Codable {+    var displayId: Int?+    var status: String?+    var type: String?+    var priority: String?+    var projectId: String?+    var search: String?+    var completionDate: DateRangeFilter?+    var lastStatusChangeDate: DateRangeFilter?+    var milestone: String?+    var milestoneDisplayId: Int?++    init(+        displayId: Int? = nil,+        status: String? = nil,+        type: String? = nil,+        priority: String? = nil,+        projectId: String? = nil,+        search: String? = nil,+        completionDate: DateRangeFilter? = nil,+        lastStatusChangeDate: DateRangeFilter? = nil,+        milestone: String? = nil,+        milestoneDisplayId: Int? = nil+    ) {+        self.displayId = displayId+        self.status = status+        self.type = type+        self.priority = priority+        self.projectId = projectId+        self.search = search+        self.completionDate = completionDate+        self.lastStatusChangeDate = lastStatusChangeDate+        self.milestone = milestone+        self.milestoneDisplayId = milestoneDisplayId+    }+}
Transit/Transit/Intents/QueryTasksIntent.swift Modified +9 / -56
diff --git a/Transit/Transit/Intents/QueryTasksIntent.swift b/Transit/Transit/Intents/QueryTasksIntent.swiftindex bccbaad..fc94ae1 100644--- a/Transit/Transit/Intents/QueryTasksIntent.swift+++ b/Transit/Transit/Intents/QueryTasksIntent.swift@@ -10,61 +10,6 @@ protocol TaskFetching {  extension TaskService: TaskFetching {} -private struct DateRangeFilter: Codable {-    var relative: String?-    var from: String?-    var toDate: String?--    enum CodingKeys: String, CodingKey {-        case relative-        case from-        case toDate = "to"-    }--    init(relative: String? = nil, from: String? = nil, toDate: String? = nil) {-        self.relative = relative-        self.from = from-        self.toDate = toDate-    }-}--private struct QueryFilters: Codable {-    var displayId: Int?-    var status: String?-    var type: String?-    var priority: String?-    var projectId: String?-    var search: String?-    var completionDate: DateRangeFilter?-    var lastStatusChangeDate: DateRangeFilter?-    var milestone: String?-    var milestoneDisplayId: Int?--    init(-        displayId: Int? = nil,-        status: String? = nil,-        type: String? = nil,-        priority: String? = nil,-        projectId: String? = nil,-        search: String? = nil,-        completionDate: DateRangeFilter? = nil,-        lastStatusChangeDate: DateRangeFilter? = nil,-        milestone: String? = nil,-        milestoneDisplayId: Int? = nil-    ) {-        self.displayId = displayId-        self.status = status-        self.type = type-        self.priority = priority-        self.projectId = projectId-        self.search = search-        self.completionDate = completionDate-        self.lastStatusChangeDate = lastStatusChangeDate-        self.milestone = milestone-        self.milestoneDisplayId = milestoneDisplayId-    }-}- // swiftlint:disable type_body_length  /// Queries tasks with optional filters via JSON input. Exposed as "Transit: Query Tasks"@@ -236,6 +181,7 @@ struct QueryTasksIntent: AppIntent {         if let nullKey = object.first(where: { $0.value is NSNull })?.key {             return .failure(.invalidInput(hint: "Filter \"\(nullKey)\" must not be null"))         }+        var emptyDateFilterKeys = Set<String>()         for filterKey in ["completionDate", "lastStatusChangeDate"] {             guard let rawDateFilter = object[filterKey] else { continue }             guard let dateFilter = rawDateFilter as? [String: Any] else {@@ -244,10 +190,15 @@ struct QueryTasksIntent: AppIntent {             for fieldKey in ["relative", "from", "to"] where dateFilter[fieldKey] is NSNull {                 return .failure(.invalidInput(hint: "Filter \"\(filterKey).\(fieldKey)\" must not be null"))             }+            if dateFilter.isEmpty {+                emptyDateFilterKeys.insert(filterKey)+            }         }-        guard let filters = try? JSONDecoder().decode(QueryFilters.self, from: data) else {+        guard var filters = try? JSONDecoder().decode(QueryFilters.self, from: data) else {             return .failure(.invalidInput(hint: "Expected valid JSON object"))         }+        filters.completionDate?.isEmptyObject = emptyDateFilterKeys.contains("completionDate")+        filters.lastStatusChangeDate?.isEmptyObject = emptyDateFilterKeys.contains("lastStatusChangeDate")         return .success(filters)     } @@ -290,10 +241,12 @@ struct QueryTasksIntent: AppIntent {      @MainActor private static func validateDateFilters(_ filters: QueryFilters) -> IntentError? {         if let completionDate = filters.completionDate,+           !completionDate.isEmptyObject,            dateRange(from: completionDate) == nil {             return .invalidInput(hint: "Invalid completionDate filter format")         }         if let lastStatusChangeDate = filters.lastStatusChangeDate,+           !lastStatusChangeDate.isEmptyObject,            dateRange(from: lastStatusChangeDate) == nil {             return .invalidInput(hint: "Invalid lastStatusChangeDate filter format")         }
Transit/TransitTests/QueryTasksIntentDateFilterTests.swift Modified +27 / -0
diff --git a/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift b/Transit/TransitTests/QueryTasksIntentDateFilterTests.swiftindex 18efdf7..f191385 100644--- a/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift+++ b/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift@@ -363,4 +363,31 @@ struct QueryTasksIntentDateFilterTests {         #expect(parsed.count == 1)         #expect(parsed.first?["name"] as? String == "Idea A")     }++    @Test("Empty date filter objects are no-ops for both date fields")+    func emptyDateFilterObjectsReturnAllTasks() throws {+        let inputs = [+            "{\"completionDate\":{}}",+            "{\"lastStatusChangeDate\":{}}",+            "{\"completionDate\":{},\"lastStatusChangeDate\":{}}"+        ]++        for input in inputs {+            let svc = try makeServices()+            let project = makeProject(in: svc.context)+            makeTask(in: svc.context, project: project, name: "Task A", displayId: 1)+            makeTask(in: svc.context, project: project, name: "Task B", displayId: 2, status: .done)+            makeTask(in: svc.context, project: project, name: "Task C", displayId: 3, status: .abandoned)++            let result = QueryTasksIntent.execute(+                input: input,+                projectService: svc.project,+                taskService: svc.task,+                milestoneService: svc.milestone+            )++            let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String })+            #expect(names == ["Task A", "Task B", "Task C"], "Input \(input) must not filter tasks")+        }+    } }
Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swift Added +110 / -0
diff --git a/Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swift b/Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swiftnew file mode 100644index 0000000..9007346--- /dev/null+++ b/Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swift@@ -0,0 +1,110 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++@MainActor @Suite(.serialized)+struct QueryTasksIntentEmptyDateFilterTests {++    private struct Services {+        let task: TaskService+        let project: ProjectService+        let milestone: MilestoneService+        let context: ModelContext+    }++    private func makeServices() throws -> Services {+        let testContainer = try TestModelContainer()+        let context = testContainer.context+        let store = InMemoryCounterStore()+        let allocator = DisplayIDAllocator(store: store)+        return Services(+            task: TaskService(modelContext: context, displayIDAllocator: allocator),+            project: ProjectService(modelContext: context),+            milestone: MilestoneService(modelContext: context, displayIDAllocator: allocator),+            context: context+        )+    }++    @discardableResult+    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+    }++    @discardableResult+    private func makeTask(+        in context: ModelContext,+        project: Project,+        name: String,+        displayId: Int,+        status: TaskStatus = .idea,+        completionDate: Date? = nil,+        lastStatusChangeDate: Date? = nil+    ) -> TransitTask {+        let task = TransitTask(name: name, type: .feature, project: project, displayID: .permanent(displayId))+        StatusEngine.initializeNewTask(task)+        if status != .idea {+            StatusEngine.applyTransition(task: task, to: status)+        }+        if let completionDate {+            task.completionDate = completionDate+        }+        if let lastStatusChangeDate {+            task.lastStatusChangeDate = lastStatusChangeDate+        }+        context.insert(task)+        return task+    }++    private func parseJSONArray(_ string: String) throws -> [[String: Any]] {+        let data = try #require(string.data(using: .utf8))+        return try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]])+    }++    @Test("Empty date filters do not override other filters")+    func emptyDateFiltersRemainNoOpsWhenCombinedWithOtherFilters() throws {+        let svc = try makeServices()+        let project = makeProject(in: svc.context)+        makeTask(in: svc.context, project: project, name: "Idea", displayId: 1)+        makeTask(in: svc.context, project: project, name: "Done", displayId: 2, status: .done)++        let result = QueryTasksIntent.execute(+            input: "{\"completionDate\":{},\"lastStatusChangeDate\":{},\"status\":\"done\"}",+            projectService: svc.project,+            taskService: svc.task,+            milestoneService: svc.milestone+        )++        let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String })+        #expect(names == ["Done"])+    }++    @Test("Unknown nested keys remain ignored when a valid date bound is present")+    func unknownNestedKeysDoNotChangeValidDateFilters() throws {+        for field in ["completionDate", "lastStatusChangeDate"] {+            let svc = try makeServices()+            let project = makeProject(in: svc.context)+            makeTask(+                in: svc.context,+                project: project,+                name: "Included",+                displayId: 1,+                status: .done,+                completionDate: Date.now,+                lastStatusChangeDate: Date.now+            )++            let result = QueryTasksIntent.execute(+                input: "{\"\(field)\":{\"from\":\"2020-01-01\",\"unexpected\":null}}",+                projectService: svc.project,+                taskService: svc.task,+                milestoneService: svc.milestone+            )++            let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String })+            #expect(names == ["Included"], "Unknown nested key must not alter \(field) filtering")+        }+    }+}
Transit/TransitTests/QueryTasksIntentNullFilterTests.swift Modified +11 / -0
diff --git a/Transit/TransitTests/QueryTasksIntentNullFilterTests.swift b/Transit/TransitTests/QueryTasksIntentNullFilterTests.swiftindex de2eb62..d12b26d 100644--- a/Transit/TransitTests/QueryTasksIntentNullFilterTests.swift+++ b/Transit/TransitTests/QueryTasksIntentNullFilterTests.swift@@ -164,6 +164,17 @@ struct QueryTasksIntentNullFilterTests {         }     } +    @Test func unknownNestedDateFilterFieldsAreRejected() throws {+        try expectInvalidInput(+            "{\"completionDate\":{\"unexpected\":\"value\"}}",+            expectedHint: "Invalid completionDate filter format"+        )+        try expectInvalidInput(+            "{\"lastStatusChangeDate\":{\"unexpected\":\"value\"}}",+            expectedHint: "Invalid lastStatusChangeDate filter format"+        )+    }+     @Test func scalarCompletionDateFilterIsRejected() throws {         try expectInvalidInput("{\"completionDate\":\"today\"}", expectedHint: "Expected valid JSON object")     }
specs/bugfixes/empty-task-query-date-filters-are-rejected/report.md Added +99 / -0
diff --git a/specs/bugfixes/empty-task-query-date-filters-are-rejected/report.md b/specs/bugfixes/empty-task-query-date-filters-are-rejected/report.mdnew file mode 100644index 0000000..ba26991--- /dev/null+++ b/specs/bugfixes/empty-task-query-date-filters-are-rejected/report.md@@ -0,0 +1,99 @@+# Bugfix Report: Empty Task Query Date Filters Are Rejected++**Date:** 2026-08-02+**Status:** Fixed+**Ticket:** T-2036++## Description of the Issue++`QueryTasksIntent` rejects a present but empty `completionDate` or `lastStatusChangeDate` object even though the Shortcut intent design defines `{}` as a valid no-op date filter. Callers using either empty date object receive `INVALID_INPUT` instead of the unfiltered task set.++**Reproduction steps:**+1. Seed tasks in more than one status.+2. Query with `{"completionDate":{}}`, `{"lastStatusChangeDate":{}}`, or both fields as empty objects.+3. Observe that the intent returns an `INVALID_INPUT` object rather than all seeded tasks.++**Impact:** Medium correctness issue for JSON/Shortcuts callers. Empty date filter objects cannot be used as valid no-op filters, while the malformed-input contract must remain strict.++## Investigation Summary++The investigation followed the systematic debugging workflow: inspect the raw JSON boundary, trace date-filter values through Codable decoding and validation, and compare the behavior with the design and existing T-1644/T-1799/T-1819 regressions.++- **Symptoms examined:** Empty date objects are decoded into non-optional `DateRangeFilter` values whose optional fields are all `nil`; `validateDateFilters` then reports an invalid format.+- **Code inspected:** `Transit/Transit/Intents/QueryTasksIntent.swift`, `QueryTasksIntentDateFilterTests.swift`, `QueryTasksIntentNullFilterTests.swift`, `DateFilterHelpers.swift`, the Shortcut design, and prior date-filter bugfix reports.+- **Hypotheses tested:** The filtering path already treats a missing parsed range as no filtering. The defect is at validation, where an empty object and malformed non-empty object are indistinguishable after Codable decoding. Existing nested-null preflight must remain intact.++## Discovered Root Cause++`parseInput` validates nested date-filter object shape and then decodes into `DateRangeFilter`, whose optional `relative`, `from`, and `to` properties all become `nil` for `{}`. After Codable erases the raw object's presence/emptiness, `validateDateFilters` calls `dateRange(from:)`; the resulting `nil` is currently treated as malformed regardless of whether the original object was empty or contained invalid non-empty content.++**Defect type:** Missing presence-aware validation / data transformation issue.++**Why it occurred:** The T-1644 presence preflight retained nested null validation but did not retain whether a recognized date-filter object had any keys. The existing date parser intentionally returns `nil` for invalid content and for no date bounds, so validation needs the raw emptiness distinction before decoding.++**Contributing factors:** Codable optional decoding intentionally maps omitted and explicit-null values to `nil`; date filters also intentionally support open bounds, so the decoded representation cannot be used alone to distinguish valid no-op input from malformed content.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Intents/QueryTasksIntent.swift` — During raw JSON preflight, record which recognized date-filter objects are genuinely empty; apply that marker after Codable decoding and skip date-format validation only for those objects. Non-empty objects continue through the existing strict parser and validation.+- `Transit/Transit/Intents/QueryTasksIntent+FilterTypes.swift` — Keep the Codable filter models separate and add the transient `isEmptyObject` marker to `DateRangeFilter`.+- `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift` — Add regressions for each empty date-filter field and both fields together, asserting the complete seeded task set is returned.+- `Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swift` — Add focused regressions proving empty filters compose with other filters and unknown nested keys remain ignored alongside valid bounds for both date fields.+- `Transit/TransitTests/QueryTasksIntentNullFilterTests.swift` — Add non-empty unknown-key regressions proving malformed date objects remain `INVALID_INPUT`.+- `CHANGELOG.md` — Record the T-2036 fix under Unreleased.++**Approach rationale:** The raw JSON dictionary is already inspected before Codable for top-level and nested null validation. Retaining only the emptiness bit at that boundary preserves the existing Codable/date-parser behavior, makes `{}` a no-op through the existing `flatMap(dateRange)` path, and prevents malformed non-empty objects from being mistaken for no filtering.++**Alternatives considered:**+- Treat every decoded date filter with no parsed range as a no-op — rejected because malformed non-empty objects would be accepted and could broaden queries.+- Add custom Codable presence tracking for every nested field — rejected as unnecessary; only object emptiness needs to survive decoding for this contract.+++## Regression Test++**Test files:** `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift` and `Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swift`+**Test names:** `emptyDateFilterObjectsReturnAllTasks`, `emptyDateFiltersRemainNoOpsWhenCombinedWithOtherFilters`, and `unknownNestedKeysDoNotChangeValidDateFilters`++**What it verifies:** `completionDate: {}`, `lastStatusChangeDate: {}`, and both empty objects together are accepted as no-op filters and return the complete seeded task set. Empty filters do not override an unrelated status filter, and unknown nested keys remain ignored when a valid recognized date bound is present for either date field.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Intents/QueryTasksIntent.swift` | Preserved empty date-object presence before Codable and skipped validation only for valid empty no-op filters. |+| `Transit/Transit/Intents/QueryTasksIntent+FilterTypes.swift` | Added the transient decoded date-filter emptiness marker and relocated filter models. |+| `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift` | Added regressions for both empty date-filter fields and the combined case, proving all seeded tasks are returned. |+| `Transit/TransitTests/QueryTasksIntentEmptyDateFilterTests.swift` | Added composition and unknown-key compatibility regressions for both date fields. |+| `Transit/TransitTests/QueryTasksIntentNullFilterTests.swift` | Added malformed non-empty unknown-key regressions. |+| `specs/bugfixes/empty-task-query-date-filters-are-rejected/report.md` | Investigation and resolution record. |+| `CHANGELOG.md` | Unreleased T-2036 fixed entry. |++## Verification++**Automated:**+- [x] Red regression run confirmed `emptyDateFilterObjectsReturnAllTasks` failed before the fix (`make test-quick`; focused test reported by the test runner).+- [x] Regression and full macOS unit suite pass (`make test-quick`).+- [x] Linters/validators pass (`make lint`; SwiftData ownership guard and SwiftLint report zero violations).++**Manual verification:**+- `emptyDateFilterObjectsReturnAllTasks` covers `completionDate: {}`, `lastStatusChangeDate: {}`, and both fields together, and compares the returned names with all three seeded tasks.+- Existing T-1644 nested-null and malformed-shape tests continue to pass.+- Existing T-1799 strict date, T-1819 reversed-range, open-bound, relative-precedence, and inclusive-range tests continue to pass.+- New unknown-key cases return field-specific `Invalid <field> filter format` errors instead of being treated as no-op filters.++## Prevention++**Recommendations to avoid similar bugs:**+- Preserve raw JSON presence and emptiness for optional Codable filter objects before decoding.+- Keep empty-object no-op cases paired with malformed non-empty object regressions for every presence-sensitive filter.+- Validate malformed content before applying filters so invalid input cannot broaden a query.++## Related++- T-1644 — nested null date-filter fields must remain rejected.+- T-1799 — absolute date strings remain strict.+- T-1819 — reversed absolute ranges remain rejected.+- `specs/shortcuts-friendly-intents/design.md` — empty date-filter objects are no-op filters.

Things to double-check

Known iOS simulator baseline

Prior exact-base/head simulator comparison recorded the same three unrelated UI failures on both revisions: TransitUITests.testClearAll, TransitUITests.testEditViewPreservesTaskMilestone, and DataMaintenanceUITests.testDataMaintenanceGoldenPath. The full iOS unit suites reached completion, and the T-2036 diff contains no UI implementation or UI-test files. This is retained as known environmental baseline evidence, not a current-head product finding; the required post-rebase checks for this review were the passing macOS make test-quick and make lint.

Exact refs and remote state

Local HEAD, local upstream, remote branch, and PR head all resolve to 0e90c0fb9bfea8912097210d9f914d795d323fea; base is c23e9a423c760980cb63b5ece01db260e33fc222; ahead/behind is 0/0; the dedicated worktree is clean; PR #213 is open and mergeable.

No review-side repository changes

The independent review made no source, test, configuration, push, or merge changes. The only mutation performed for this request is replacement of the requested rendered review artifact.