transit PR #202 commits 4 files 5 touched lines +269 / -2 focused tests 28 passed

Pre-push review: T-1644 — Reject nested date filter nulls

Independent review of ArjenSchwarz/transit#202 at the exact final head c24f9f9118127d38e7f01f83e989f0610f238ae4.

At a glance

  • Identity: PR #202 is open and unmerged; its base is 275ac5cfc063f1196041470fba115448d8f2dd29 and its final head is c24f9f9118127d38e7f01f83e989f0610f238ae4.
  • Behavior: completionDate and lastStatusChangeDate reject null relative, from, and to fields before decoding, while omitted bounds remain open-ended.
  • Validation: focused intent/date-filter tests passed 28/28; make test-quick, make lint, and git diff --check passed; fresh claude-review CI passed on the exact head.
  • Review state: GitHub reports zero review threads, zero unresolved threads, and no merge state; the detached validation worktree is clean.

Verdict

Ready to push

The T-1644 change is ready to push from a correctness perspective: the exact PR head is verified, the nested-null contract is implemented for both date filters, the focused regression suites pass, macOS unit tests and lint pass, the qualifying GitHub check is green, and the PR has zero review threads. The full iOS suite reported three unrelated UI failures; one was reproduced on the PR base and the other two are outside the changed intent/test paths. This remains a validation caveat to track separately, not a T-1644 blocker.

Review findings

1 raised · 0 fixed · 1 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Query task filters accept date ranges such as {"from": "2026-01-01"}. Before this fix, a nested value such as {"from": null} could be decoded as an absent value and accidentally broaden the query. The implementation now checks the raw JSON and returns INVALID_INPUT with a field-specific hint whenever a supported nested date-filter field is explicitly null.

Why it matters

Explicit null is different from omitting a key. Omission keeps the existing open-ended range behavior; null is rejected so callers cannot silently get a less restrictive query than they requested.

Validation boundary

QueryTasksIntent.parseInput already parses the input through JSONSerialization before decoding the typed request. The change uses that raw object to inspect completionDate and lastStatusChangeDate, requires each present nested filter to be a JSON object, and rejects NSNull for relative, from, or to. Valid objects then continue through the existing Codable path.

Trade-off

The validation is intentionally local to the two date-filter keys. It preserves existing error handling for malformed shapes and existing date-range semantics, while tests cover both filters, all three fields, omitted bounds, malformed values, and the no-broadening guarantee.

Contract and edge cases

DateRangeFilter stores optional relative, from, and toDate, with toDate mapped from the external to key. Swift decoding of optionals cannot distinguish omitted keys from explicit JSON null, so raw-object validation is the correct layer for preserving that distinction. The guard rejects scalar/array nested shapes with Expected valid JSON object and rejects explicit null with Filter "key.field" must not be null. Values with only one bound still flow to DateFilterHelpers, which models the missing side as open. The diff adds no production path outside QueryTasksIntent and no schema or persistence change.

Important changes — detailed

Reject explicit nulls at the raw JSON boundary

Transit/Transit/Intents/QueryTasksIntent.swift

Why it matters. Preserves the API distinction between an omitted optional bound and an explicitly null bound, preventing silently broadened task queries.

What to look at. QueryTasksIntent.parseInput: nested date-filter validation

Takeaway. When Codable optionals erase the distinction between omitted and null JSON fields, validate the raw JSON object before decoding.
Rationale. The existing parser already retains the JSONSerialization object before Codable decoding, so the fix adds a narrow validation step without changing the request model or downstream query logic. (inferred — not stated by the author)

Cover both supported date-filter keys and all nested fields

Transit/TransitTests/QueryTasksIntentNullFilterTests.swift

Why it matters. The regression surface is symmetric across completion and last-status-change dates and across relative/from/to.

What to look at. Six exact nested-null cases plus malformed-shape and no-broadening cases

Takeaway. For mirrored input fields, parameterized or systematically named tests help prove that validation is not accidentally limited to one key.
Rationale. The tests exercise the externally visible contract and assert both rejection details and protection against returning broad results. (inferred — not stated by the author)

Preserve open-ended date-range semantics

Transit/TransitTests/QueryTasksIntentDateFilterTests.swift

Why it matters. Rejecting null must not change the valid behavior of omitting one side of a date range.

What to look at. completionDateFromOnlyKeepsOpenUpperBound and completionDateToOnlyKeepsOpenLowerBound

Takeaway. Input-hardening changes should include positive tests for nearby valid forms so stricter validation does not narrow the contract accidentally.
Rationale. The tests verify that a present bound with an omitted opposite bound remains an open interval through the existing helper semantics. (inferred — not stated by the author)

Document the bugfix contract and release note

specs/bugfixes/query-tasks-nested-date-filter-null-fields/report.md

Why it matters. The behavior change and validation evidence remain discoverable for future callers and reviewers.

What to look at. Bugfix report and CHANGELOG entry

Takeaway. A small API-contract fix benefits from documenting both the invalid form and the intentionally preserved valid forms.
Rationale. The report records the observed failure mode, implementation boundary, tests, and compatibility considerations. (inferred — not stated by the author)

Key decisions

Validate raw JSON instead of changing DateRangeFilter decoding

This keeps the typed model's optional properties and existing open-bound behavior unchanged while allowing the parser to distinguish explicit null from omission. The decision is inferred from the existing JSONSerialization-then-Codable flow and the narrow production diff.

(inferred — not stated by the author.)
Reject malformed nested date-filter shapes explicitly

A present date-filter value must be a JSON object. Scalars and arrays return Expected valid JSON object rather than reaching a decoder failure or being ignored, preserving fail-closed input handling.

(inferred — not stated by the author.)
Do not modify unrelated UI tests

The full iOS run exposed three UI failures outside the changed intent/date-filter paths. No source or test code was changed during this review; the failures are recorded as validation caveats and should be tracked separately.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
lowFull iOS simulator validation<code>make test</code> completed with 1,166 passed and 3 failed UI tests: <code>TransitUITests.testClearAll()</code>, <code>TransitUITests.testEditViewPreservesTaskMilestone()</code>, and <code>DataMaintenanceUITests.testDataMaintenanceGoldenPath()</code>. The failures are outside the changed QueryTasksIntent/date-filter paths. A PR-base reproduction using the corrected DataMaintenance selector reproduced the DataMaintenance failure on base.Skipped as a T-1644 blocker because the relevant intent tests, macOS suite, lint, diff check, and fresh head CI are green; track the pre-existing/orthogonal UI failures separately.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 321243e..275e494 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 +- `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). - 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. 
Transit/Transit/Intents/QueryTasksIntent.swift Modified +9 / -1
diff --git a/Transit/Transit/Intents/QueryTasksIntent.swift b/Transit/Transit/Intents/QueryTasksIntent.swiftindex be582c2..f470012 100644--- a/Transit/Transit/Intents/QueryTasksIntent.swift+++ b/Transit/Transit/Intents/QueryTasksIntent.swift@@ -236,6 +236,15 @@ struct QueryTasksIntent: AppIntent {         if let nullKey = object.first(where: { $0.value is NSNull })?.key {             return .failure(.invalidInput(hint: "Filter \"\(nullKey)\" must not be null"))         }+        for filterKey in ["completionDate", "lastStatusChangeDate"] {+            guard let rawDateFilter = object[filterKey] else { continue }+            guard let dateFilter = rawDateFilter as? [String: Any] else {+                return .failure(.invalidInput(hint: "Expected valid JSON object"))+            }+            for fieldKey in ["relative", "from", "to"] where dateFilter[fieldKey] is NSNull {+                return .failure(.invalidInput(hint: "Filter \"\(filterKey).\(fieldKey)\" must not be null"))+            }+        }         guard let filters = try? JSONDecoder().decode(QueryFilters.self, from: data) else {             return .failure(.invalidInput(hint: "Expected valid JSON object"))         }@@ -386,7 +395,6 @@ struct QueryTasksIntent: AppIntent {             toDateString: filter.toDate         )     }- }  // swiftlint:enable type_body_length
Transit/TransitTests/QueryTasksIntentDateFilterTests.swift Modified +58 / -0
diff --git a/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift b/Transit/TransitTests/QueryTasksIntentDateFilterTests.swiftindex f911994..c52c1df 100644--- a/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift+++ b/Transit/TransitTests/QueryTasksIntentDateFilterTests.swift@@ -194,6 +194,64 @@ struct QueryTasksIntentDateFilterTests {         #expect(parsed.first?["name"] as? String == "Today Done")     } +    @Test func completionDateFromOnlyKeepsOpenUpperBound() throws {+        let svc = try makeServices()+        let project = makeProject(in: svc.context)+        let calendar = Calendar.current+        let today = calendar.startOfDay(for: Date.now)+        let fromDate = calendar.date(byAdding: .day, value: -1, to: today)!+        let beforeFrom = calendar.date(byAdding: .day, value: -2, to: today)!++        makeTask(in: svc.context, project: project, name: "Before From", displayId: 1, completionDate: beforeFrom)+        makeTask(in: svc.context, project: project, name: "At From", displayId: 2, completionDate: fromDate)+        makeTask(in: svc.context, project: project, name: "After From", displayId: 3, completionDate: today)++        let dateFormatter = DateFormatter()+        dateFormatter.dateFormat = "yyyy-MM-dd"+        dateFormatter.locale = Locale(identifier: "en_US_POSIX")+        dateFormatter.timeZone = TimeZone.current+        let fromDateString = dateFormatter.string(from: fromDate)++        let result = QueryTasksIntent.execute(+            input: "{\"completionDate\":{\"from\":\"\(fromDateString)\"}}",+            projectService: svc.project,+            taskService: svc.task,+            milestoneService: svc.milestone+        )++        let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String })+        #expect(names == ["At From", "After From"])+    }++    @Test func completionDateToOnlyKeepsOpenLowerBound() throws {+        let svc = try makeServices()+        let project = makeProject(in: svc.context)+        let calendar = Calendar.current+        let today = calendar.startOfDay(for: Date.now)+        let toDate = calendar.date(byAdding: .day, value: -1, to: today)!+        let beforeTo = calendar.date(byAdding: .day, value: -2, to: today)!++        makeTask(in: svc.context, project: project, name: "Before To", displayId: 1, completionDate: beforeTo)+        makeTask(in: svc.context, project: project, name: "At To", displayId: 2, completionDate: toDate)+        makeTask(in: svc.context, project: project, name: "After To", displayId: 3, completionDate: today)++        let dateFormatter = DateFormatter()+        dateFormatter.dateFormat = "yyyy-MM-dd"+        dateFormatter.locale = Locale(identifier: "en_US_POSIX")+        dateFormatter.timeZone = TimeZone.current+        let toDateString = dateFormatter.string(from: toDate)++        let result = QueryTasksIntent.execute(+            input: "{\"completionDate\":{\"to\":\"\(toDateString)\"}}",+            projectService: svc.project,+            taskService: svc.task,+            milestoneService: svc.milestone+        )++        let names = Set(try parseJSONArray(result).compactMap { $0["name"] as? String })+        #expect(names == ["Before To", "At To"])+    }+     @Test func invalidDateFilterReturnsInvalidInputError() throws {         let svc = try makeServices() 
Transit/TransitTests/QueryTasksIntentNullFilterTests.swift Modified +90 / -1
diff --git a/Transit/TransitTests/QueryTasksIntentNullFilterTests.swift b/Transit/TransitTests/QueryTasksIntentNullFilterTests.swiftindex 7f9cfb2..de2eb62 100644--- a/Transit/TransitTests/QueryTasksIntentNullFilterTests.swift+++ b/Transit/TransitTests/QueryTasksIntentNullFilterTests.swift@@ -63,7 +63,7 @@ struct QueryTasksIntentNullFilterTests {         return svc     } -    private func expectInvalidInput(_ input: String) throws {+    private func expectInvalidInput(_ input: String, expectedHint: String? = nil) throws {         let svc = try makeSeededServices()         let result = QueryTasksIntent.execute(             input: input, projectService: svc.project, taskService: svc.task,@@ -71,6 +71,9 @@ struct QueryTasksIntentNullFilterTests {         )         let parsed = try parseJSON(result)         #expect(parsed["error"] as? String == "INVALID_INPUT", "input \(input) should be rejected")+        if let expectedHint {+            #expect(parsed["hint"] as? String == expectedHint, "input \(input) returned an unstable error hint")+        }     }      // MARK: - Explicit Null Rejection@@ -104,6 +107,92 @@ struct QueryTasksIntentNullFilterTests {         try expectInvalidInput("{\"lastStatusChangeDate\":null}")     } +    @Test func nullCompletionDateRelativeIsRejected() throws {+        try expectInvalidInput(+            "{\"completionDate\":{\"relative\":null,\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"}}",+            expectedHint: "Filter \"completionDate.relative\" must not be null"+        )+    }++    @Test func nullCompletionDateFromIsRejected() throws {+        try expectInvalidInput(+            "{\"completionDate\":{\"from\":null,\"to\":\"2026-01-31\"}}",+            expectedHint: "Filter \"completionDate.from\" must not be null"+        )+    }++    @Test func nullCompletionDateToIsRejected() throws {+        try expectInvalidInput(+            "{\"completionDate\":{\"from\":\"2026-01-01\",\"to\":null}}",+            expectedHint: "Filter \"completionDate.to\" must not be null"+        )+    }++    @Test func nullLastStatusChangeDateRelativeIsRejected() throws {+        try expectInvalidInput(+            "{\"lastStatusChangeDate\":{\"relative\":null,\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"}}",+            expectedHint: "Filter \"lastStatusChangeDate.relative\" must not be null"+        )+    }++    @Test func nullLastStatusChangeDateFromIsRejected() throws {+        try expectInvalidInput(+            "{\"lastStatusChangeDate\":{\"from\":null,\"to\":\"2026-01-31\"}}",+            expectedHint: "Filter \"lastStatusChangeDate.from\" must not be null"+        )+    }++    @Test func nullLastStatusChangeDateToIsRejected() throws {+        try expectInvalidInput(+            "{\"lastStatusChangeDate\":{\"from\":\"2026-01-01\",\"to\":null}}",+            expectedHint: "Filter \"lastStatusChangeDate.to\" must not be null"+        )+    }++    @Test func malformedNestedDateFilterFieldsAreRejected() throws {+        let inputs = [+            "{\"completionDate\":{\"relative\":false}}",+            "{\"completionDate\":{\"from\":42}}",+            "{\"completionDate\":{\"to\":[]}}",+            "{\"lastStatusChangeDate\":{\"relative\":{}}}",+            "{\"lastStatusChangeDate\":{\"from\":false}}",+            "{\"lastStatusChangeDate\":{\"to\":42}}"+        ]++        for input in inputs {+            try expectInvalidInput(input, expectedHint: "Expected valid JSON object")+        }+    }++    @Test func scalarCompletionDateFilterIsRejected() throws {+        try expectInvalidInput("{\"completionDate\":\"today\"}", expectedHint: "Expected valid JSON object")+    }++    @Test func arrayLastStatusChangeDateFilterIsRejected() throws {+        try expectInvalidInput("{\"lastStatusChangeDate\":[]}", expectedHint: "Expected valid JSON object")+    }++    @Test func malformedNestedDateFiltersDoNotBroadenResults() throws {+        let inputs = [+            (+                "{\"completionDate\":{\"from\":null,\"to\":\"2026-01-31\"}}",+                "Filter \"completionDate.from\" must not be null"+            ),+            (+                "{\"lastStatusChangeDate\":{\"from\":\"2026-01-01\",\"to\":null}}",+                "Filter \"lastStatusChangeDate.to\" must not be null"+            ),+            ("{\"completionDate\":\"today\"}", "Expected valid JSON object"),+            ("{\"lastStatusChangeDate\":[]}", "Expected valid JSON object")+        ]++        for (input, expectedHint) in inputs {+            // Parsing the response as an object proves the malformed request did not+            // become a successful (and potentially broader) task array.+            try expectInvalidInput(input, expectedHint: expectedHint)+        }+    }+     @Test func nullMilestoneIsRejected() throws {         try expectInvalidInput("{\"milestone\":null}")     }
specs/bugfixes/query-tasks-nested-date-filter-null-fields/report.md Added +111 / -0
diff --git a/specs/bugfixes/query-tasks-nested-date-filter-null-fields/report.md b/specs/bugfixes/query-tasks-nested-date-filter-null-fields/report.mdnew file mode 100644index 0000000..dc8419f--- /dev/null+++ b/specs/bugfixes/query-tasks-nested-date-filter-null-fields/report.md@@ -0,0 +1,111 @@+# Bugfix Report: Query Tasks Nested Date Filter Null Fields++**Date:** 2026-08-02+**Status:** Fixed+**Ticket:** T-1644++## Description of the Issue++`QueryTasksIntent` rejects explicit JSON `null` values for top-level filters, but it accepts explicit `null` values inside the `completionDate` and `lastStatusChangeDate` filter objects. `JSONDecoder` decodes those present nulls into optional `nil` values, making them indistinguishable from omitted fields.++For example, `{"completionDate":{"from":null}}` can become an open-ended date filter rather than a malformed request. A missing bound can broaden the result set compared with the caller's intended bounded query.++**Reproduction steps:**+1. Seed tasks with dates inside and outside a bounded date range.+2. Call `QueryTasksIntent` with `{"completionDate":{"from":null}}` or the equivalent `lastStatusChangeDate` payload.+3. Observe that the nested null is not rejected before date-filter parsing.++**Impact:** Medium correctness issue for Shortcut/JSON callers. Malformed date filters can be accepted with altered or broader query semantics. No task data is mutated.++## Investigation Summary++The investigation followed the project's systematic debugging workflow: inspect the input boundary, trace decoded values through validation and filtering, and compare the behavior with the existing top-level null contract.++- **Symptoms examined:** Nested `relative`, `from`, or `to` nulls are accepted while top-level null filters return `INVALID_INPUT`; malformed date-filter shapes must not turn into an unfiltered query.+- **Code inspected:** `Transit/Transit/Intents/QueryTasksIntent.swift`, `QueryTasksIntentNullFilterTests.swift`, `QueryTasksIntentDateFilterTests.swift`, `DateFilterHelpers.swift`, `TransitShortcuts.swift`, and all direct `QueryTasksIntent.execute` callers.+- **Hypotheses tested:** The issue is not in date range calculation or task filtering. It is caused by presence information being erased during Codable decoding. Non-object nested filters already fail through JSONDecoder and must retain their existing generic `INVALID_INPUT` contract.++## Discovered Root Cause++`QueryTasksIntent.parseInput` checks only the top-level `[String: Any]` dictionary for `NSNull` values, then decodes into `QueryFilters`. The nested `DateRangeFilter` fields are optional strings, so synthesized `decodeIfPresent` maps a present JSON null to `nil`, exactly as it does for an omitted field. `dateRange(from:)` then passes those nil values to `DateFilterHelpers.parseDateFilter`, which can interpret a missing bound as an open-ended range.++**Defect type:** Missing presence-aware validation / data transformation issue.++**Why it occurred:** The original null guard was added at the filter-object level but did not recurse into the two nested date-filter dictionaries before decoding.++**Contributing factors:** Codable's optional decoding intentionally erases the distinction between omitted and explicit null values. The date filter API intentionally allows omitted bounds, so the erased value can be interpreted as valid input with different query semantics.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Intents/QueryTasksIntent.swift` — Added a raw JSON preflight for `completionDate` and `lastStatusChangeDate`. Each recognized date filter must be an object, and `relative`, `from`, and `to` are checked for `NSNull` before `JSONDecoder` runs. Non-object shapes retain the existing `Expected valid JSON object` error contract, while unknown nested keys remain ignored.+- `Transit/TransitTests/QueryTasksIntentNullFilterTests.swift` — Added six nested-null regressions with exact error-hint assertions, malformed nested-field type regressions, malformed object-shape regressions, and a no-broadening regression covering both filters.+- `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift` — Added query-level regressions proving omitted `from` and `to` bounds preserve open-ended range semantics.+- `CHANGELOG.md` — Added the T-1644 fixed entry under `Unreleased`.++**Approach rationale:** The raw `JSONSerialization` object is already available for top-level null validation, so extending that preflight preserves the existing Codable model and date parsing semantics while retaining presence information only where required. Fixed field iteration order makes the nested null error deterministic.++**Alternatives considered:**+- Add custom `Decodable` presence tracking to `DateRangeFilter` — rejected as a larger change than the existing raw-object validation pattern.+- Reject every null-valued unknown nested key — rejected to avoid changing Codable's established behavior for ignored fields.+- Treat nested null as omitted — rejected because it can widen a bounded date query.++## Regression Test++**Test files:**+- `Transit/TransitTests/QueryTasksIntentNullFilterTests.swift`+- `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift`++**Test names:**+- `nullCompletionDateRelativeIsRejected`+- `nullCompletionDateFromIsRejected`+- `nullCompletionDateToIsRejected`+- `nullLastStatusChangeDateRelativeIsRejected`+- `nullLastStatusChangeDateFromIsRejected`+- `nullLastStatusChangeDateToIsRejected`+- `malformedNestedDateFilterFieldsAreRejected`+- `scalarCompletionDateFilterIsRejected`+- `arrayLastStatusChangeDateFilterIsRejected`+- `malformedNestedDateFiltersDoNotBroadenResults`+- `completionDateFromOnlyKeepsOpenUpperBound`+- `completionDateToOnlyKeepsOpenLowerBound`++**What it verifies:** Every recognized nested date-filter field rejects explicit JSON null under both date filters with stable field-path hints. Malformed scalar/array nested fields and date-filter shapes retain `INVALID_INPUT`, and malformed requests produce an error object rather than a broader task result. Query-level open-bound tests prove omitted `from` and `to` fields retain existing inclusive semantics.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Intents/QueryTasksIntent.swift` | Presence-aware nested date-filter validation before Codable decoding |+| `Transit/TransitTests/QueryTasksIntentNullFilterTests.swift` | Nested null, malformed-type, stable-error, and no-broadening regressions |+| `Transit/TransitTests/QueryTasksIntentDateFilterTests.swift` | Open-ended `from`/`to` range-semantics regressions |+| `specs/bugfixes/query-tasks-nested-date-filter-null-fields/report.md` | Investigation and resolution record |+| `CHANGELOG.md` | Unreleased T-1644 fixed entry |++## Verification++**Automated:**+- [x] Red regression run confirmed all six nested-null cases failed before the fix (`xcodebuild ... -only-testing:TransitTests/QueryTasksIntentNullFilterTests`, exit 65).+- [x] Focused regression suite passes after the fix (all `QueryTasksIntentNullFilterTests` cases passed).+- [x] Full unit test suite passes (`make test-quick`, exit 0).+- [x] Linters/validators pass (`make lint`, exit 0; ownership guard and SwiftLint pass).++**Manual verification:**+- Existing `omittedKeysStillReturnAllTasks` confirms omitted filters retain existing semantics, while `completionDateFromOnlyKeepsOpenUpperBound` and `completionDateToOnlyKeepsOpenLowerBound` verify omitted nested bounds remain open and inclusive.+- Exact nested-null hints are asserted for all six `relative`/`from`/`to` cases under both date filters.+- Existing top-level null regressions continue to pass, preserving their established `Filter \"<key>\" must not be null` contract.+- Malformed nested field types and scalar/array date-filter shapes continue to return `INVALID_INPUT` rather than broadening results; the no-broadening regression parses the response as an error object, not a task array.++## Prevention++**Recommendations to avoid similar bugs:**+- Validate presence-sensitive JSON fields from the `JSONSerialization` object before decoding into optional Codable properties.+- Treat nested filter objects as a separate validation boundary and preserve the existing error contract for malformed shapes.+- Add paired omitted-versus-explicit-null regressions whenever optional JSON fields are introduced.++## Related++- T-1644 — QueryTasksIntent accepts nested null date filter fields.+- `QueryTasksIntentNullFilterTests` — existing top-level explicit-null contract.

Things to double-check

Exact PR and commit identity

Verified PR #202 is OPEN, mergedAt is null, base is main at 275ac5cfc063f1196041470fba115448d8f2dd29, and head branch T-1644/bugfix-query-tasks-intent-accepts-nested-null-date-filter-fields points to c24f9f9118127d38e7f01f83e989f0610f238ae4. The head is a commit, its parent is c8ac752da6a69dd9dc55af4c53ca75fedfb7405d, and the base/head merge-base equals the PR base SHA.

Nested-null contract

Both completionDate and lastStatusChangeDate reject explicit null for relative, from, and to with field-specific invalid-input hints. The six exact cases pass in QueryTasksIntentNullFilterTests.

Valid omitted bounds

From-only and to-only date ranges remain open-ended through the existing date-filter helper. Both positive tests pass in QueryTasksIntentDateFilterTests.

Malformed shapes and no-broadening

Nested scalar/array shapes are rejected as invalid JSON objects, malformed nested fields do not broaden results, and omitted keys remain valid. These focused regression cases pass.

Relevant and broad validation

Focused changed suites passed 28/28. make test-quick, make lint, and git diff --check passed. The full iOS suite is not fully green because of the three unrelated UI failures documented above.

Fresh CI and review state

Fresh claude-review CI passed for c24f9f9 in run 30726148196. GitHub review-thread GraphQL returned count: 0 and unresolved: 0.

Clean status and preservation

The detached exact-head validation worktree is clean. The original checkout was not altered and retains its pre-existing modified docs/agent-notes files and untracked .kiro/settings/lsp.json; those local changes are not part of PR #202.