# A complete transaction search can still select the wrong month

Source: https://rasa.community/library/casebook/banking-statement-search/
Author: Rod Rivera
Published: 2026-10-08T09:00:00Z

Every request check passes. The search still puts a February transaction into March and leaves a March transaction out. This is the actual output from a temporary timezone mutation of the Northgate Bank fixture:

```text
{"bank_date_membership_check": "FAIL", "count": 24, "facts": {"date_range_confirmed": true, "posting_status_explicit": true, "result_scope_complete": true}, "reason": "verified_fixture_receipt", "request_guard": "succeeded", "total_debits": "$2,603.84", "variant": "utc_date"}
```

The fixture's three checks confirm the requested range, posting status, and complete pagination. None independently checks whether the returned rows belong to that range. We changed only the conversion from stored timestamps to dates. The request checks stayed green; the membership check failed.

These are offline Python calls over invented customers and transactions. No model or real bank ran. The mutation is deliberate, not a reported bank defect.

## Assert which rows belong, not just that all pages arrived

The customer asks for March 2026 in the bank's Eastern timezone. The two boundary rows explain the failed membership check:

| Row     | Stored UTC timestamp | Bank date   | Original search | UTC-date mutation |
| ------- | -------------------- | ----------- | --------------- | ----------------- |
| TX-0004 | 1 March, 04:30       | 28 February | Excluded        | Included          |
| TX-0028 | 1 April, 01:45       | 31 March    | Included        | Excluded          |

A completeness flag answers whether every page of the selected query arrived. It cannot prove that the query selected the right population. Keep an independent row-membership assertion beside that flag. The check below requires TX-0028 and excludes TX-0004; it does not infer correctness from a plausible total.

Use the [pinned transaction-search example](https://github.com/RasaHQ/rasa-community-resources/tree/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-text-banking-statement-search-gpt). Our selected `SearchTests`, `PeriodTests`, and `GroundingTests` receipt contains 20 passing checks without skips. `test_dates_are_eastern_time_not_utc` tests row membership; `test_statements_run_on_billing_cycles` tests a separate period distinction.

Save this as `/tmp/banking-statement-search-check.py`. From the example's `tests` directory, run `PYTHONPATH=. python3 -B /tmp/banking-statement-search-check.py`:

```python
import json
from datetime import datetime
import test_guard as t
h = t.nb
original = h.local_date
for variant in ("bank_timezone", "utc_date"):
    h.local_date = original if variant == "bank_timezone" else lambda s: datetime.fromisoformat(s.replace("Z", "+00:00")).date()
    try:
        _, result = t.search(["checking, March 2026, everything"], "March 2026", "everything", "checking")
        accepted, reason = t.lab_outcome(result["facts"], h.load_contract())
        membership_ok = "TX-0028" in t.ids(result) and "TX-0004" not in t.ids(result)
        print(json.dumps({"variant": variant, "request_guard": accepted, "reason": reason, "facts": result["facts"], "bank_date_membership_check": "PASS" if membership_ok else "FAIL", "count": result["count"], "total_debits": result["total_debits"]}, sort_keys=True))
    finally:
        h.local_date = original
```

Actual offline output:

```text
{"bank_date_membership_check": "PASS", "count": 25, "facts": {"date_range_confirmed": true, "posting_status_explicit": true, "result_scope_complete": true}, "reason": "verified_fixture_receipt", "request_guard": "succeeded", "total_debits": "$2,620.88", "variant": "bank_timezone"}
{"bank_date_membership_check": "FAIL", "count": 24, "facts": {"date_range_confirmed": true, "posting_status_explicit": true, "result_scope_complete": true}, "reason": "verified_fixture_receipt", "request_guard": "succeeded", "total_debits": "$2,603.84", "variant": "utc_date"}
```

The baseline and mutant both return `succeeded` from the lab's request guard. Only the membership result distinguishes them. The changed total is a consequence of selecting different rows, not evidence of an arithmetic error. This one boundary assertion is not a general oracle for every possible date range.

:::diagram{title="Request acceptance and row membership are separate checks"}

```dot
rankdir=TB;
a [label="Filter chooses rows"];
b [label="Read all pages"];
c [label="Range confirmed, status explicit,
all pages complete"];
d [label="Request guard succeeds
even with wrong date basis"];
e [label="Independent bank-date oracle
checks boundary row identities"];
a -> b -> c -> d;
a -> e;
```

:::

## Put the date basis inside the filter

The [pinned conversion](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-text-banking-statement-search-gpt/lib/history.py#L231-L232) converts an instant into the bank's timezone. The [filter](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-text-banking-statement-search-gpt/lib/history.py#L599-L614) then compares that date:

```python
def local_date(stamp: str) -> date:
    return datetime.fromisoformat(stamp.replace("Z", "+00:00")).astimezone(TZ).date()

# Inside _matching, after account, owner and posting-status checks:
d = local_date(t["at"])
if not (query.start <= d <= query.end):
    continue
```

Converting only for display, after filtering, cannot restore a discarded row. Deriving dates from instants requires the service owner to name the timezone and date meaning. A change to either needs new boundary tests, including daylight-saving transitions. Those extensions were not run here.

Ask the service owner whether the contract supplies an instant or a date-only field, and whether it means purchase, posting, or settlement date. Keep independent membership checks whichever contract you choose. This is setup guidance; no date-only adapter was tested here.

:::checkpoint{id="statement-bank-date" question="Every page arrived and every request check passed. What still needs independent evidence?" options="That the selected row identities match the bank-date range|That the model can add money|That pagination finished" answer="0"}
:::solution{title="Completeness does not establish membership"}
The mutated filter selected the wrong rows before pagination. Complete delivery of that result preserves the mistake. Assert the two boundary identities against the service's date contract.
:::
:::

## Do not substitute a search for a statement

The fixture's March checking statement covers 6 February through 5 March. Its March transaction search covers 1–31 March. Correct timezone conversion does not make those requests equivalent. Ask which document or range the customer wants, and preserve that scope in the receipt.

:::cta{href="/library/casebook/banking-transfer/" label="Test request checks that can hide another missing check"}
The transfer example separates overlapping refusal conditions with targeted mutations. Use the same habit when a complete result looks reassuring.
:::