Skip to content

Phase 20.1: finish integration de-masking; 9 shipped bugs found and fixed - #2

Merged
mariomeyer merged 27 commits into
mainfrom
gsd/phase-20.1-integration-de-masking
Jul 26, 2026
Merged

Phase 20.1: finish integration de-masking; 9 shipped bugs found and fixed#2
mariomeyer merged 27 commits into
mainfrom
gsd/phase-20.1-integration-de-masking

Conversation

@mariomeyer

@mariomeyer mariomeyer commented Jul 25, 2026

Copy link
Copy Markdown
Member

Finishes the integration-test de-masking quick task 1 started, fixes every live failure it deferred, and adds a guard so the pattern cannot come back. Also includes a .planning/ reconciliation that preceded it.

De-masking worked as intended: it surfaced nine real bugs that were shipping behind green tests.

The bugs

# Defect Impact
1 Named-location weather lookups all returned NOT_FOUND Searched 50 of 844 cities; only lat/lon worked
2 wx_get_climate_trends never returned a filtered record Wrong field names for the ahccd-trends collection
3 Every ArcGIS Hub "list everything" call 400'd on empty q Shared client — affected 4 modules
4 York Region text filters matched 0 rows instead of 420 Case-sensitive LIKE against uppercase data
5 StatCan empty coordinates crashed on Pydantic validation Blamed the service for an empty lookup
6 Malformed empty SDMX bodies reported as INVALID_INPUT Blamed the caller for StatCan's broken JSON
7 Both Toronto TTC tools were permanently dead Pinned dataset+resource uuids 404'd after a republish
8 Datastore refused nested values Defeated its own cross-module-SQL purpose
9 16 tools had zero exception handling Timeouts escaped as raw ToolError

Bug 7 is why the phase required a reachability check before tolerating any endpoint: applying the "tolerate transient errors" pattern without it would have marked a dead tool green forever. It now returns 9,361 stops.

Bugs 2 and 4, and the fixtures behind 2, share a root cause with the StatCan defects fixed in the earlier commits here: tests and fixtures written to agree with the code rather than the API. test_client.py built its expected values from the very constant under test, so the assertions passed for any map — including one that reported monthly CPI as "Bi-monthly" for three phases.

The guard

tests/test_integration_test_quality.py enforces one rule by AST: every path through an integration test must reach an assertion. It catches one-armed response guards, bare return, and data-dependent pytest.skip — including idioms nobody has written yet.

It runs in the default unit suite, so reintroducing a mask fails on every commit rather than only in a live run. The detector is itself tested (6 cases, including that the hardened pattern must not flag). Exemptions require a mandatory reason and are capped at 10% of the suite.

Numbers

  • Masking idioms 62 → 0, with one declared exemption
  • Baseline live failures 18 → 0 (re-measured; the June list was stale)
  • Unit tests 3081 → 3124, coverage 97.07%
  • ruff 94, below the 96 baseline this work started from

Known caveat

No clean full-suite live run. Every cluster passes in isolation (weather 38, BC 9, YR 8, AB 7, BOC 14, StatCan/SDMX 27, Toronto 10, IRCC 6, NS/QC 58, Drug/Nutrient 17), but running all 339 live tests back-to-back times out on Drug/Nutrient under Health Canada rate limiting. That is upstream load, not a defect — and bug 9's fix means those timeouts now arrive as structured envelopes the assertions classify correctly instead of dying unhandled. Recorded in 20.1-VERIFICATION.md rather than glossed over.

Note on scope

6 of the 19 commits are .planning/-only (state reconciliation, phase artifacts). Happy to strip them onto a source-only branch if you'd prefer a tighter diff.

STATE.md had never advanced past "Roadmap created for v1.1" (2026-04-07), so it
reported "Phase 7 of 10, 0%" after 71 plans had shipped. That staleness had
propagated: two phases carried open verification that the artifacts contradicted,
and the requirement/roadmap bookkeeping had drifted project-wide.

Every flip below is evidence-driven — a ROADMAP plan box is only ticked when its
SUMMARY.md exists on disk; a requirement is only Complete when its phase's plans
all have summaries.

Phase 17 (Alberta) — was human_needed, now passed:
  - BM25 discovery re-run live through MCP Client: alberta_ tools rank top-5 on
    all 5 probe queries, #1 on 4 of 5.
  - French verified live: NOT_FOUND -> "Jeu de données introuvable: ...";
    alberta_list_categories(lang="fr") -> _meta.lang='fr'.
  - All three doc-tracking gaps closed.

Phase 11 (IRCC) — was mechanically stale, now passed:
  The report was written at 15:14 on 2026-04-08, before plan 11-04 (a gap-closure
  plan) committed its summary at 15:57, so it covered 3 of 4 plans. 11-UAT, run at
  22:00 the same day, was already 10/10 against the post-11-04 build. Added three
  truths covering 11-04's merged-header parser and re-stamped.

Bookkeeping:
  - REQUIREMENTS.md: 117 traceability rows Planned -> Complete, 100 checklist
    boxes -> [x]
  - ROADMAP.md: 61 plan checkboxes -> [x]
  - STATE.md: Phase 20.1, 43%, 15/35 phases, status planning

Phase 08 UAT — tests 3-6 had been left `result: [pending]` and never run. Ran them
live; they surface a real defect, so 08-UAT is now status: diagnosed:
  - Gap 1 (major): FREQUENCY_CODES in statcan/constants.py:28 is shifted from code
    6 onward — monthly CPI reports as "Bi-monthly". sc_get_code_sets, which proxies
    StatCan live, correctly says 6 = Monthly, so the server contradicts itself.
    The unit tests cannot catch it: they assert the map against itself.
  - Gap 2 (minor): sc_get_series_info_by_vector returns uom_code with no decoded
    label.
  Filed as .planning/todos/pending/2026-07-25-fix-statcan-frequency-codes-map.md.

Also noted, not fixed: phases 15 (BC) and 16 (Quebec) shipped without any
REQUIREMENTS.md entries, so they are invisible to the traceability table.

No source changes. Suite at reconciliation: 3032 passed, 2 skipped, 97.05% coverage.
… published set

Both hand-written WDS decode maps were shifted relative to StatCan's published
code set, so every sc_ tool that decodes them reported wrong values.

FREQUENCY_CODES was shifted from code 6 onward and truncated at 13:

  code  shipped            StatCan published
  6     Bi-monthly         Monthly
  7     Quarterly          Bimonthly
  9     Annual             Quarterly
  11    Every 3 years      Semi-annual
  12    Irregular          Annual
  14-21 (missing)          Every 3/4/5/10 years, Occasional, Occasional Q/M/D

It also invented codes 3, 5, 8 and 10, which StatCan does not publish.

SCALAR_FACTOR_CODES carried the same defect, found while fixing the first, and
is arguably worse — the scalar factor is a magnitude multiplier, so code N means
10^N. The shipped map read 1="thousands" where upstream means "tens": a 100x
misread of every scaled observation. It also invented an 888 entry.

Live reproduction before the fix (CPI 18100004, vector 41690973):

  sc_get_data_by_vector(vector_id=41690973, n=5)
    ref_per: 2026-06-01, 2026-05-01, 2026-04-01   <- one month apart
    frequency: "Bi-monthly"                        <- every row

The server contradicted itself: sc_get_code_sets proxies the live endpoint and
returned the correct 6="Monthly", while every other sc_ tool decoded against the
stale local copy.

Why nothing caught it:
  - The unit tests built their expected value with FREQUENCY_CODES.get(...),
    asserting the map against itself. Tautological — they pass for any map.
  - The fixtures were fabricated to match the wrong map: monthly CPI was given
    frequencyCode 5, which is not a StatCan code at all.
  - 08-UAT test 4 ("e.g., 6 = Monthly") was designed to catch exactly this and
    was left `result: [pending]`, never run, for three phases.

Changes:
  - constants.py: both maps transcribed from getCodeSets, with a comment naming
    the source and warning that codes are non-contiguous.
  - resources.py: data://statcan/frequency-codes and .../scalar-factor-codes
    rebuilt, including corrected French labels (they repeated the same errors).
  - __tests__/test_code_maps.py (new, 39 tests): literal expected labels, exact
    set equality, "code 6 is Monthly not Bimonthly" regression, guards against
    re-inventing absent codes, and catalog-matches-constant checks.
  - __tests__/conftest.py: fixture codes corrected to real upstream values
    (5->6 monthly, 7->9 quarterly, 9->12 annual, scalar 6->2 hundreds).
  - __tests__/test_client.py: de-tautologised — expected labels are now literals.
  - tests/integration: TestStatCanCodeSetDrift (4 tests) fails if the local maps
    ever diverge from live getCodeSets again, including the CPI-is-monthly case.

Closes 08-UAT.md Gap 1. Gap 2 (sc_get_series_info_by_vector returns uom_code
with no decoded label) remains open and is tracked in .planning/todos/pending/.

Verified: 3071 passed, 2 skipped, 97.06% coverage (was 3032/97.05%).
Integration drift tests pass against live StatCan. ruff and pyright clean on all
touched files.
… sessions

08-UAT.md Gap 2: sc_get_series_info_by_vector returned `uom_code` with no
decoded label, even though `frequency` and `scalar_factor` are both decoded in
the same response. The stated UAT expectation ("unit of measure label") was
therefore unmet.

Investigating turned up a THIRD instance of the Gap 1 bug class: the
data://statcan/uom-codes catalog was entirely fabricated — all 15 entries wrong.

  code  catalog claimed        StatCan publishes
  0     Not applicable         (null)
  1     Number                 1981=100
  17    Canadian dollars       2002=100
  20    Percentage             2007=100
  301   Tonnes (thousands)     Vehicle-kilometres

Code 17 is the CPI index base — it matches that series' own title, "Consumer
Price Index (CPI), 2002=100". An agent following the catalog would have read it
as a dollar amount.

UOM is decoded from the live getCodeSets payload rather than a hardcoded map:
upstream publishes 464 codes, many of them index bases that cannot be guessed
from a name, and the fabricated catalog is direct evidence that hand-maintenance
fails for this set. The lookup shares get_code_sets()'s existing 7-day cache
entry (new _raw_code_sets_cached seam), and degrades to None on any failure so a
getCodeSets outage cannot take down series-info lookups.

Verified live after the fix — both gaps closed on the same call:

  sc_get_series_info_by_vector(vector_id=41690973)
    frequency_code 6  frequency "Monthly"    <- Gap 1
    uom_code 17       uom "2002=100"         <- Gap 2

Changes:
  - schemas.py: SeriesInfo.uom (str | None, defaults None)
  - client.py: _raw_code_sets_cached / _raw_code_sets / _uom_label;
    _flatten_series_info_async wired into both series-info call paths
  - resources.py: uom catalog replaced with a verified 31-code subset plus a
    _note pointing at sc_get_code_sets for the remaining 433
  - __tests__/test_uom_decode.py (new, 10 tests): schema field, lookup, null and
    unknown codes, outage degradation, both call paths, catalog honesty
  - __tests__/test_client.py: series-info now makes two cached_fetch calls, so
    the cache-key/TTL assertions pin the specific call instead of reading
    `.call_args` (last call), and the limiter assertions state the real contract
    (both upstream requests rate-limited) instead of a stale count of one
  - integration: 2 more drift tests (decoded uom, catalog subset matches live)

Also closed three debug sessions that were fixed long ago but left open:
  - ircc-header-parsing.md -> resolved/. Recommendation "Option 4" shipped as
    written in plan 11-04; verified the DATASET_PARSE_CONFIG table matches the
    proposed skip_rows/header_rows/label_cols key-for-key.
  - bc-api-get-dict-mismatch.md, bc-bilingual-error-messages.md: deleted as
    stale duplicates — resolved/ already holds the newer completed versions
    (resolved 2026-04-11, with resolved_by, verification and files_changed).
    They had been copied into resolved/ rather than moved.

Verified: 3081 passed, 2 skipped, 97.06% coverage (was 3071). Integration drift
suite 6/6 against live StatCan. ruff clean on touched files; pyright errors on
the statcan module went 86 -> 78.
…times todo

Phases 15 (British Columbia) and 16 (Quebec) shipped 20 and 18 tools with zero
REQUIREMENTS.md entries — both were planned with "Requirements: TBD (no explicit
REQ IDs yet)", so neither appeared anywhere in the traceability table. Two whole
provinces of delivered work were invisible to the project's own coverage record.

Backfilled BC-01..BC-22 and QC-01..QC-19 from shipped code, each verified against
the module at write time:
  - BC: 5 discovery + 15 BCGW WFS-backed tools, with the actual BCGW layer names
    and filter parameters; BC-21 records shared/ogc.py as a reusable third portal
    technology (not BC-specific); BC-22 covers conventions + 6 prompts/7 resources.
  - QC: 5 Données Québec CKAN discovery + 13 curated MSSS/MTQ/MELCCFP/MAMH/
    Hydro-Québec tools, marking the three discovery-only ones (forest fire archive,
    water quality, protected areas) as such; QC-19 covers conventions.

Both section headers state they were backfilled and why, so the provenance is not
mistaken for up-front requirements. ROADMAP "Requirements: TBD" lines for phases 15
and 16 replaced with the ID lists.

Every executed phase now has traceability coverage:
  7(9) 8(15) 9(4) 10(4) 11(9) 12(8) 13(12) 14(14) 15(22) 16(19)
  17(27) 18(18) 19(15) 20(18) 40(20)
The 20 remaining "Requirements: TBD" lines are all unplanned phases (20.1, 21-39),
which is correct — requirements get written at planning time.

Also refreshed the cross-Canada ER wait-times research todo (filed 2026-04-12,
before phases 17-20). Its research plan pointed at hosts those phases proved dead:
data.manitoba.ca is unreachable, data.saskatchewan.ca does not exist, and
data.novascotia.ca is Socrata rather than CKAN. Added what each phase already
settled — Alberta has AHS facility layers but no occupancy data (AB-16, Pitfall 9),
Saskatchewan health is deferred because SHA publishes no public FeatureServer, and
Nova Scotia shipped ns_get_health_facilities with no wait-time dataset found. The
item stays open as backlog; only its stale premises changed.

No source changes. Suite unchanged: 3081 passed, 2 skipped.
… tests

De-masking `test_weather_current_scenarios.py` immediately surfaced a real bug
that the masks had been hiding: every named-location weather lookup was broken.

    wx_get_current_conditions(location="Toronto") -> NOT_FOUND
    wx_get_forecast(location="Vancouver")         -> NOT_FOUND
    wx_get_current_conditions(lat=45.4, lon=-75.7) -> works fine

Root cause: with only `location` given, `bbox` stayed None, so ogc_fetch pulled
an unordered first-50 page out of the 844-city citypage collection and filtered
THAT by name. Toronto and Vancouver are not in the arbitrary first 50, so they
were never found. Both fetch_current_conditions and fetch_forecast had the
defect independently.

Why nothing caught it:
  - The integration tests wrapped their assertions in `if "_meta" in data:`, so
    a NOT_FOUND response skipped the body and the test passed green.
  - The unit tests mock ogc_fetch to return the matching city, which assumes
    away the very filtering under test.

Fix: pass a server-side `name.{lang}` property filter so all 844 cities are
searched upstream (confirmed: name.en=Toronto matches 2, name.fr=Montréal
matches 1; the wrong param name returns 0 rather than being ignored).

Two subtleties handled:
  - The upstream filter is a case-insensitive TOKEN match, so name.en=Toronto
    returns ["Toronto Island", "Toronto"] in that order. _pick_city prefers an
    exact name match, so "weather in Toronto" no longer answers with Toronto
    Island. "Ottawa" still resolves to "Ottawa (Kanata - Orleans)" via the
    containing-match fallback.
  - The containing-match check doubles as a client-side verification: if the
    upstream ever ignores the name parameter and returns an unrelated city, we
    return None rather than confidently answering with the wrong place. This
    preserves the safety the old client-side filter provided.

Tests: 7 new in TestLocationNameLookupIsServerSide asserting the filter reaches
ogc_fetch (rather than mocking it away), French uses name.fr, exact beats
partial, and lat/lon still uses bbox with no name filter.

De-masking, per the Phase 20.1 CONTEXT decisions:
  - tests/integration/conftest.py gains assert_live_or_transient() and
    assert_rows(). The first implements the D-08 hardened pattern: an error
    response is tolerated only if the code is UPSTREAM_ERROR or RATE_LIMITED,
    so a real outage stays green while NOT_FOUND on a call that should succeed
    fails loudly — which is exactly how this bug was caught. The second refuses
    an empty payload unless the caller documents why empty is valid.
  - All 9 tests in test_weather_current_scenarios.py rewritten onto them. The
    `assert "_meta" in data or "error" in data` lines are gone; they passed for
    both outcomes and asserted nothing.
  - pyproject registers the tolerates_upstream_error marker (D-14).

Verified: 3081 unit tests pass; weather module 294 pass; the 9 live
weather/current scenarios pass against MSC GeoMet.
…te tests

De-masking `test_weather_climate_scenarios.py` surfaced that
`wx_get_climate_trends` has never returned a filtered record.

The ahccd-trends collection names its properties with bilingual
double-underscore suffixes, unlike the SCREAMING_CASE used by climate-daily and
climate-normals. The client used the latter convention throughout:

    sent      CLIMATE_IDENTIFIER=...      real  station_id__id_station
    sent      MEASUREMENT_TYPE=...        real  measurement_type__type_mesure
    read      TREND / YEAR_BEGIN / ...    real  trend_value__valeur_tendance, ...

So every filtered call matched zero records, and because _flatten_trend read the
same wrong keys, even an unfiltered call produced rows of all-None.

The collection also publishes PRECIPITATION only — "rain", "snow",
"total_precip". The tool docstring advertised "temperature", which matches
nothing; corrected, along with a note explaining the absence despite the AHCCD
name. The flattener now also surfaces station_name, period, year_range,
province and elevation_m, which the real payload carries and the old key set
discarded.

Why nothing caught it: the fixture `sample_trend_feature` was written with the
same invented SCREAMING_CASE keys as the client, so the unit tests agreed with
the code rather than with the API — the same self-referential pattern found in
StatCan on 2026-07-25. The integration test asserted shape only `if
data["data"]:`, so an empty list skipped the body and passed. Fixture replaced
with a real feature captured from the live collection.

Also fixed in the integration file, triaged as test-drift rather than tool bugs:
  - test_climate_normals asked for normals with station 6158731, which exists in
    climate-daily but not climate-normals. Ottawa CDA is indexed there as
    6105976 (966 records). Split into OTTAWA_STATION / OTTAWA_NORMALS_STATION
    with a comment, since the two collections genuinely disagree.
  - test_climate_trends asked for "temperature"; now asks for "total_precip".

De-masked all three `if data["data"]:` guards via assert_rows(), which refuses
an empty payload unless the caller documents why empty is valid. Ottawa CDA
January 2024 daily data, its normals, and national precipitation trends are all
closed/published series — empty means broken, so none carry an exemption.

Verified: 3098 unit tests pass, 97% coverage; the 8 live weather/climate
scenarios pass; test_quality docstring checks still pass.
…AQHI ids

Completes the weather de-masking (D-10, D-12). All four weather files plus
test_live_apis.py now assert in every branch; the only masks left in the suite
are the 29 in test_tool_scenarios.py.

Files: aqhi_hydro (9 guards), marine_severe_snow (7), collections_summary (7),
live_apis (1). Every `assert "_meta" in data or "error" in data` line is gone —
it was true for both outcomes and asserted nothing.

One more class of invented test data surfaced, triaged as test-drift (D-04):

  AQHI location_ids are opaque 5-letter MSC codes, not province-prefixed
  strings. The tests used "ON106" and "ON-01", which match nothing, and the
  masks hid the resulting NOT_FOUND. Resolved from the live collections by
  filtering location_name_en: Ottawa is FEVNT. Worth noting for future work —
  the id differs per collection for the same city (Toronto observations FDQBU,
  Toronto forecasts FCWYG), so they are not interchangeable.

Error-path tests kept, now explicitly two-armed rather than one-armed:
  - test_flood_risk_invalid_station / test_collection_not_found: NOT_FOUND is
    the CORRECT answer for a nonexistent station or collection, so these assert
    the error rather than tolerating it. They are error-path tests, not masks,
    and need no exemption.
  - test_hurricane_tracks_off_season and test_thunderstorm_outlook: the dead
    `pass` branches now assert that a list payload is genuinely empty, so a
    populated-but-wrong-shaped response fails instead of slipping through.
  - test_bill_keyword_search_limitation asserted a known upstream limitation
    only `if data["objects"]:`. Session 42-1 always has bills, so an empty list
    means the request broke rather than the limitation ending; now asserted.

Payload emptiness is now explicit everywhere: assert_rows() refuses an empty
result unless the caller documents why empty is valid. Three tests legitimately
do (no active weather alerts in Ontario, none Canada-wide, no radar echoes near
Ottawa) and say so inline. The rest — NS marine areas, Ontario hydrometric
stations, Ottawa AQHI, climate-stations, MSC collection list — are published
series where empty means broken.

Verified: 3098 unit tests pass; 38 live weather scenarios, 18 live_apis and 10
marine/severe/snow scenarios all pass.
…asets

BC was never in quick task 1's five-province scope. It carried 4 _meta guards,
1 data-key guard, and 5 pytest.skip calls.

The skips were the worse half (D-11). Both bc_query_features routing tests
discovered a dataset by searching BCDC and skipped when the search came back
empty or matched nothing WFS-queryable. A skip reports neither pass nor fail, so
"BCDC search returned no results" silently meant the routing path under test was
never exercised — and nothing distinguished that from the tests passing.

Replaced the discovery dance with two pinned, canonical BCDC package ids:
  22c7cb44 BC Wildfire Fire Perimeters - Historical (queryable_via_wfs=True)
  7ec1a555 BC Greenhouse Gas Emissions (queryable_via_wfs=False, 11 resources)

Both routing tests now always run. If either dataset disappears the test fails
loudly, which is the intent — a vanished canonical dataset is news, not a reason
to skip. Added test_dataset_details_exposes_wfs_routing_metadata to assert the
two-step CKAN→WFS workflow's contract directly: details must carry
queryable_via_wfs and object_name, which is what bc_query_features routes on.

The file-parser test previously only asserted on a locally-collected dict and
never called bc_query_features at all; it now asserts the non-WFS branch's
preconditions against a known file-resource dataset.

De-masked the 4 _meta guards onto assert_live_or_transient, and tightened the
empty cases that were silently acceptable:
  - 2023 fire perimeters (a record BC fire season) must return features
  - PROVINCIAL PARK must return parks — BC has hundreds
  - mineral tenure must return claims — BC has thousands, so empty means the
    tenure_type filter is broken rather than the province being quiet

Verified: 9 live BC scenarios pass against BCDC + BCGW WFS.
…-mask YR+AB

De-masking York Region and Alberta surfaced two more real bugs, one of them in
shared code affecting four modules.

1. shared/arcgis_hub.py sent `q=` on no-query listings.

Every ArcGIS Hub portal rejects an empty q with HTTP 400 — verified against
aurora, newmarket, york_region, markham and manitoba: all five return 400 for
q='' and 200 when q is omitted. So every "list everything" call failed:
aurora_list_categories and newmarket_search_datasets both returned
UPSTREAM_ERROR, which read as an upstream outage. Now omitted when the query is
empty or whitespace, mirroring the startindex=0 handling on the next line.

This is the shared Hub client, so the fix reaches York Region, Alberta, Manitoba
and Saskatchewan — the same blast radius as the Phase 19 startindex fix.

2. York Region text filters were case-sensitive against uppercase data.

ArcGIS stores the attributes uppercase ("WATERBRIDGE", "MAIN"), but the WHERE
clauses compared the caller's raw string. Live counts:

    STREET LIKE '%Main%'          0 rows
    UPPER(STREET) LIKE '%MAIN%'   420 rows
    NAME   LIKE '%Main%'          40 rows   (mixed-case data)
    UPPER(NAME) LIKE '%MAIN%'     42 rows

An agent asking for "Main Street in Markham" got an empty result indistinguish-
able from a legitimate no-match. Fixed all three LIKE filters (transit stops,
Markham addresses, Markham roads) to uppercase both sides. Three existing unit
tests asserted the old case-sensitive clause and were updated — they encoded the
defect.

3. Shape drift resolved in favour of the tools (D-05, case by case).

The three York Region failures in the baseline were tests asserting
`isinstance(data["data"], list)` against tools that return
`{"features": [...], "count": N, "truncated": bool}`. That dict is the
deliberate, consistent convention for geospatial feature queries across BC WFS
and all four ArcGIS modules — the count and truncation flag are meaningful to an
agent. The tools are right; the tests drifted. Added assert_feature_payload() to
conftest so the contract is asserted in one place.

Alberta's three unhardened guards de-masked the same way. Its hospital test
previously wrapped the count-range assertion in `if isinstance(count, int)`, so
a missing count skipped the check entirely; the count is now required. Active
fires deliberately does NOT assert a feature count — zero is a legitimate winter
reading — but the shape is still checked.

Masking idioms remaining in the suite: 9, down from 62 at the start of the phase.

Verified: 3105 unit tests pass; York Region 140 unit / 8 live, Alberta 7 live,
shared hub 22 unit — all green. The 3 baseline York Region failures are fixed.
… shape change

D-06 asked whether the BOC dict shape was intentional before deciding. It was:
commit 485afdb (2026-04-09) "refactor: extract shared reshape utilities and apply
to BOC tools" deliberately moved the observation tools to a series-keyed format
so the label and description are stated once instead of repeated on every row:

    {"FXUSDCAD": {"label": "USD/CAD", "description": "...",
                  "observations": {"2026-07-23": 1.39}}}

That commit updated the BOC unit tests but not the integration tests or the
module docs, so 5 live tests (4 BOC + 1 cross-module) have been failing since
April with KeyError: 0 — indexing a dict with [0].

Verdict: tests drifted, tool is right. No code change.

Rewrote the 5 tests against the real contract via a new assert_series_payload()
helper, which requires each named series to be present AND to carry a non-empty
observations map. The assertions got stronger in the process:
  - USD/CAD checks every observation is in a plausible range, not just the first
  - the EUR date-range test asserts a full month of banking days (>=15) and that
    a single-currency query returns exactly one series
  - the multi-series test names all three series explicitly
  - the policy-rate test asserts observations exist before range-checking them,
    rather than indexing blindly

Also updated docs/modules/bank-of-canada.md, whose response example still showed
the April flat-list shape — the same drift one layer out. The JSON block is now
valid and matches what the tool actually returns.

Verified: 14 live BOC + cross-module scenarios pass; 3105 unit tests pass.
Baseline failures remaining: 10 of 18 (BOC 5 and York Region 3 now fixed).
Six live failures in the StatCan/SDMX cluster, three real defects among them.

1. A coordinate with no series crashed on Pydantic validation.

StatCan answers getSeriesInfoFromCubePidCoord for an unpopulated coordinate with
status=SUCCESS, responseStatusCode=2 ("Invalid cube and series combination") and
every field null. _unwrap only special-cased responseStatusCode 2 when the OUTER
status was not SUCCESS, so this fell through to SeriesInfo and the tool surfaced
"UPSTREAM_ERROR: 6 validation errors for SeriesInfo" — blaming the service for
what is really an empty lookup. Added _require_series(), which raises a clean
ValueError for the no-series statuses (2, 4, 5 per getCodeSets wdsResponseStatus)
on both the by-vector and by-coordinate paths, and the tool now maps it to
NOT_FOUND.

2. Empty SDMX results are malformed JSON, reported as the caller's fault.

Asking for a key with no observations returns HTTP 200 and a body with two
surplus closing braces:

    "dataSets": [{ "action": "Information","series":{ }}}}],"structure":...
                                                      ^^^^ should be }}

resp.json() raises JSONDecodeError, which subclasses ValueError and was caught
by the handler meant for the last_n/date-range conflict — so StatCan's broken
output surfaced as "INVALID_INPUT: Expecting ',' delimiter: line 1 column 200".
_parse_sdmx_body() now recognises the empty-series marker and returns an empty
result, and any other unparseable body raises as UPSTREAM_ERROR. The genuine
INVALID_INPUT case (lastN combined with a date range) keeps its code, asserted
by a test.

3. UPSTREAM_UNAVAILABLE added to the integration TRANSIENT_CODES set.

StatCan WDS has a documented nightly maintenance window (00:00-08:30 EST) and
deliberately reports UPSTREAM_UNAVAILABLE during it. That is scheduled downtime,
not a defect — treating it as transient is what lets the suite run overnight. It
stays distinct from NOT_FOUND and INVALID_INPUT, which remain hard failures.

Test-side fixes (drift, not defects):
  - test_get_series_info_by_coord and test_get_data_by_coord asked for
    coordinate 1.1.0.0..., which identifies no published series. They now use
    2.2.0.0... (CPI all-items, vector 41690973) and assert the vector id and
    Monthly frequency. Added test_coordinate_with_no_series_is_not_found to
    cover the empty case deliberately rather than by accident.
  - test_get_bulk_vector_data expected a list; the tool returns a dict keyed by
    vector id. Per D-05, decided case by case: the key IS the answer to "which
    vector is this row from", so a flat list would repeat it on every row —
    same rationale as the BOC series-keyed shape. Tool is right, test updated.
  - Four SDMX unit-test mocks set .json() but the client now reads .text; they
    supply both.

Verified: 3111 unit tests pass, 97% coverage; 27 live StatCan + SDMX scenarios
pass. Baseline failures remaining: 4 of 18 (Toronto TTC 2, IRCC 2).
…es as JSON

The last four baseline failures. Two were real defects, both of the same
species: something pinned that upstream later moved.

1. Both TTC tools have been dead, not flaky.

D-09 required checking reachability before tolerating an error as transient —
and this is exactly why. constants pinned a dataset uuid, resource uuid and
filename:

    .../7795b45e-...-c5b0dc4b531e/resource/f17e0649-.../download/
        ttc-routes-and-schedules.zip

Toronto has since republished the feed under different uuids and a different
filename (opendata_ttc_schedules.zip). Both the pinned download URL AND the
pinned dataset uuid now 404, so toronto_get_ttc_stops and toronto_get_ttc_routes
returned "UPSTREAM_ERROR: Failed to fetch TTC GTFS stop data" — indistinguish-
able from an outage. Had the hardened pattern been applied without checking, a
permanently dead tool would have been marked green forever.

Fixed by removing GTFS_ZIP_URL entirely and resolving the ZIP resource from CKAN
package_show at call time, keyed by the dataset SLUG rather than a uuid — the
slug survives republishes, the uuids do not. constants.py carries a note saying
why the URL must not be pinned again, and a unit test asserts GTFS_ZIP_URL stays
absent. Live check after the fix: 9,361 TTC stops.

2. The datastore refused nested values.

Storing IRCC output produced "DATASTORE_ERROR: Error binding parameter 2: type
'dict' is not supported" — sqlite3's message, not an answer. IRCC reshapes
observations into {"years": {"2023": {"q1": {...}}}} by design, and the
datastore exists precisely so an agent can combine any module's output in one
SQL query, so rejecting a whole class of module output defeats its purpose.

insert_rows now serialises dict and list values to JSON text via _bind().
Scalars pass through untouched — a test asserts ints stay ints. The data stays
queryable through SQLite's JSON1 functions, which another test demonstrates:

    SELECT json_extract(years, '$."2023".total') FROM ircc_pr;

3. test_ircc_invalid_breakdown asserted an unreachable branch.

`breakdown` is Literal-typed, so Pydantic rejects an unknown value at the MCP
boundary before the tool body runs; the tool's own INVALID_INPUT branch is dead
code for that parameter. The test expected a dict and died on the ToolError. It
now asserts what actually protects the agent — that the rejection names the
parameter and lists the valid options, which a bare INVALID_INPUT string would
not. (Same phenomenon recorded in 17-VERIFICATION.md during the reconciliation.)

All 18 baseline failures are now resolved:
  BOC 5, StatCan 5, York Region 3, Toronto 2, IRCC 2, SDMX 1.

Verified: 3123 unit tests pass, 97% coverage; Toronto 116 unit / 10 live,
datastore 109 unit, IRCC 6 live.
…stream errors

The guard that makes this phase stick, plus the last of the failures.

1. tests/test_integration_test_quality.py — the anti-reintroduction guard (D-13).

Written first and used as the worklist for the whole phase; committed last so
main was never red. It parses tests/integration/ and enforces one rule: every
path through an integration test must reach an assertion. Three structural
violations are detected — a one-armed `if` on the response wrapping the
assertions, a bare `return` abandoning them, and pytest.skip. It runs in the
DEFAULT unit suite, like test_quality.py, so reintroducing a mask fails on every
commit rather than only in a live run.

The detector is itself tested (6 parametrized cases): the hardened pattern must
NOT flag, a one-armed guard must, and a conditional over local bookkeeping must
not. A guard nobody trusts gets deleted.

Two meta-tests keep the exemption honest: every exemption must state a
non-trivial reason, and exemptions may not exceed 10% of the suite.

Progress: 62 masking idioms at the start of the phase, 0 now, with exactly one
declared exemption.

2. PRESERVE audit (D-14) — the list shrank from ~10 to 1.

Re-examining each supposedly-tolerant test showed most did not need tolerance:
  - StatCan invalid-product and the BC/weather not-found tests are error-PATH
    tests where the error IS the expected answer; now asserted in both arms.
  - Quebec road works and winter road conditions genuinely can be empty, so they
    state that inline via allow_empty_reason rather than skipping the check.
  - The Quebec bridges test guarded a nullable route_name; `or ""` keeps the
    Route-204-leak assertion running on every row.
  - test_store_pr_data_to_datastore skipped when 2023 PR data was missing —
    which is exactly how it hid the datastore dict-binding bug. Now asserts.
  - The NS AMI test mixed a zone-normalization PROOF with a sex-filter probe, so
    whenever the filter matched nothing the proof silently did not run. Split:
    the proof now queries bare "ami" and asserts strictly; the sex-filter
    behaviour is a separate test carrying the sole exemption, since AMI has no
    sex column upstream and both a 400 and zero rows are correct answers.

3. drug_database and nutrient_file had ZERO exception handling.

The full live suite surfaced 9 failures that pass in isolation: under load the
Health Canada Drug API is slow enough to time out, and with no try/except in
either module the timeout escaped as a raw fastmcp ToolError rather than an
error envelope. That violates the project rule that a tool returns make_error()
and never raises, and it defeats the hardened pattern — a test cannot classify
an exception it never receives.

Added shared/envelope.py:upstream_guard(), applied under @tool to all 16 tools.
functools.wraps keeps __wrapped__ so inspect.signature still sees the real
parameters and @tool registration is unaffected — asserted by a test, since a
broken signature would silently unregister the tools.

Verified: 3124 unit tests pass, 97.07% coverage; the masking guard passes; 17
live Drug/Nutrient scenarios pass; ruff clean on all touched files.
- datastore/client.py used Any in _bind() without importing it (masked at
  runtime by `from __future__ import annotations`, but wrong)
- tests/test_envelope.py had an import above the module docstring
- dropped two more extraneous f-string prefixes

Repo ruff count is now 94, below the 96 baseline this phase started from.
…tfalls

Phase 20.1's ROADMAP title described work quick task 1 had already finished, and
its goal was still the placeholder "[Urgent work - to be planned]" (D-03).
Renamed to what the phase actually delivered, with its six plans listed.

Backfilled TEST-01..TEST-05 describing the guarantees the phase now enforces —
every path reaches an assertion, tolerance requires asserting a transient code,
reintroduction fails the default unit suite, exemptions carry a mandatory
reason, and tools return envelopes rather than raising. Added to the
traceability table.

Two cross-module pitfalls added to CLAUDE.md, both discovered the hard way:
  - Every ArcGIS Hub portal 400s on an empty `q`. Affects four modules.
  - Toronto republishes the TTC GTFS feed under fresh uuids, so the download URL
    must never be pinned — resolve it from CKAN by slug.

CLAUDE.md and .claude/rules/tests.md now state the integration-test rule with
the banned idioms spelled out, the helper usage, and the reachability caveat
that caught the dead TTC tools. Written so the next module author copies the
right pattern from the nearest example, which is how this defect spread.

Also added the six PLAN/SUMMARY pairs so the phase reads correctly in GSD state.
Phase 20.1 verified PASSED. 5/5 requirements, 18/18 baseline failures fixed,
62 masking idioms reduced to 0 with one declared exemption.

Nine real defects were found and fixed, all of which had been shipping behind
green tests:

  1. Every named-location weather lookup returned NOT_FOUND (Toronto, Vancouver)
  2. wx_get_climate_trends never returned a filtered record — wrong field names
  3. Every ArcGIS Hub "list everything" call 400'd on an empty q — 4 modules
  4. York Region text filters matched 0 rows instead of 420 for mixed case
  5. StatCan empty coordinates crashed on Pydantic validation
  6. Malformed empty SDMX bodies were reported as the caller's INVALID_INPUT
  7. Both Toronto TTC tools were permanently dead behind an UPSTREAM_ERROR
  8. The datastore refused nested values, defeating its cross-module purpose
  9. 16 tools had no exception handling, so timeouts escaped as raw ToolErrors

Three share the root cause found in StatCan during the preceding reconciliation:
tests and fixtures written to agree with the code rather than the API.

Verified: 3124 unit tests, 97.07% coverage, guard green, ruff 94 (baseline 96).
@cursor

cursor Bot commented Jul 25, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e1c533dbe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +112 to +113
except ValueError as exc:
return make_error("INVALID_INPUT", str(exc), lang=lang)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify malformed upstream JSON as an upstream error

When either Health Canada endpoint returns HTTP 200 with an HTML or malformed JSON body, httpx.Response.json() in the drug and nutrient clients raises json.JSONDecodeError, which subclasses ValueError; this handler therefore labels an upstream response failure as INVALID_INPUT. Valid tool calls will misleadingly blame the caller, and integration tests that correctly tolerate only transient UPSTREAM_ERROR responses will fail during this upstream condition. Handle JSON decoding failures as UPSTREAM_ERROR before treating genuine argument-validation ValueErrors as invalid input.

Useful? React with 👍 / 👎.

@mariomeyer
mariomeyer force-pushed the gsd/phase-20.1-integration-de-masking branch from 3e1c533 to b88dcd1 Compare July 26, 2026 01:43
Two of the four CI gates, both failing on main before this branch existed.

Catalog freshness: TOOLS.md claimed "84 tools across 8 modules" while the repo
ships 273 across 20 — stale since roughly phase 13. Regenerated. Only two lines
of the 1966-line diff come from this branch (the wx_get_climate_trends docstring
correction); the rest is twelve phases of accumulated drift.

Lint: tests/integration/test_prompts_resources_scenarios.py imported the
mcp_server fixture at module level, and every one of its 87 test parameters then
shadowed that name — 87 F811s from a single redundant import. pytest discovers
conftest fixtures automatically, so the import only ever created the collision.
Removed; all 83 tests still collect.

ruff: 96 on main -> 7 here. Remaining: 1 E402, 4 F841, 2 F541, all pre-existing.
pyright (834) is untouched and still fails the gate.
…eter

query_feature_service and get_count typed `where: str = "1=1"`, but four
Alberta call sites passed None. httpx drops None-valued params, so the
request reached ArcGIS with no `where` at all — and ArcGIS answers that
with HTTP 200 carrying an error 400 "Unable to perform query operation".
Agents saw a bogus UPSTREAM_ERROR that read as an upstream outage.

Verified live against Saskatchewan Public_Fire_Ban on 2026-07-26:
omitting `where` errors, `where=1=1` returns features.

Fixed in the shared client rather than at the four call sites so no
future caller can reintroduce it. Same class as the documented empty-`q`
Hub pitfall. Reproduction test asserts the outgoing param.
- prompts.py (alberta, manitoba, nova_scotia, saskatchewan): import Message
  from fastmcp.prompts like the other 16 modules do, not from the deeper
  fastmcp.prompts.prompt path. Pyright could not resolve the deep path, so
  it treated every symbol in those four modules as Unknown — which silently
  suppressed 99 downstream errors in their test files.
- british_columbia/client.py: _api_get returns the unwrapped CKAN `result`,
  which is a list for organization_list/tag_list. Its dict[str, Any]
  annotation was simply wrong; callers already isinstance-narrow.
- shared/parsers.py: pandas is an optional extra with an openpyxl fallback,
  so the three lazy imports are legitimately unresolvable. Marked explicitly.

Source code now type-checks clean.
Lint (7 -> 0):
- ontario/tools.py: move `import re` to the top of the module (E402)
- statcan/test_prompts_resources.py, shared/test_ogc.py: drop four dead
  assignments (F841)
- integration/test_tool_scenarios.py: drop f-prefixes with no placeholders

Type check (921 -> 0): the remaining errors were all in test files and all
noise — runtime assertions narrow prompt Message.content and Resource.read()
unions in ways pyright cannot follow, and several tests deliberately pass
invalid values to exercise error handling. Each affected test file now
declares a file-level pragma listing only the rules it actually needs.
Source code stays strictly checked, which is where every real finding came
from: the where=None bug and the four unresolved prompt imports.

All four CI gates now pass locally: ruff, pyright, 3125 tests at 97.07%
coverage, and catalog freshness.
httpx drops None-valued params, so where=None silently became "no where
clause" and ArcGIS answered with a 200-wrapped error 400 that read as an
outage. Documented alongside the empty-`q` pitfall it mirrors.
STATE.md still said "context exhaustion at 75%" from the session that was
handed off; Phase 20.1 is complete and PR #2 now has all four CI gates green.

Also records a defect found while probing ArcGIS endpoints for the where=None
fix: Alberta's whole WMB wildfire FeatureServer group returns 499 Token
Required, killing three fire tools. Its one integration test tolerates the
error via assert_live_or_transient, so it passes silently — the masking
pattern tests.md warns about. Deferred to its own phase rather than widened
into this PR.

.claude/.gitignore keeps the handoff scratch file out of git.
Addresses Codex review on PR #2 (P2, shared/envelope.py:113).

json.JSONDecodeError subclasses ValueError, so when Health Canada answers
HTTP 200 with an HTML error page, httpx's .json() raised inside a guarded
tool and fell through to the ValueError arm — reporting INVALID_INPUT and
blaming the caller for an upstream outage. It also broke live tests in a
confusing way: assert_live_or_transient tolerates only UPSTREAM_ERROR,
RATE_LIMITED and UPSTREAM_UNAVAILABLE, so a real outage failed as if the
arguments were wrong.

Handle JSONDecodeError ahead of the ValueError arm. A second test pins that
genuine argument-validation ValueErrors still return INVALID_INPUT, so the
fix cannot swallow real caller errors.

Verified the mechanism rather than assuming it: JSONDecodeError does
subclass ValueError, httpx .json() raises it on an HTML body, and both the
drug and nutrient clients call .json() inside the guarded path.

Also records in STATE.md that the same masking is latent in ~40 ValueError
arms downstream of shared/http.py:api_get, which has no decode guard. That
is pre-existing and best fixed once inside api_get; left out of this PR.
Sequenced between 20.1 and 21 rather than at the end of the milestone.
Phases 21-39 add ~19 more modules that would each copy the current
inconsistent handler shape, so normalizing across 24 modules now is far
cheaper than across 43 later, and this is the root cause of the same
masking class 20.1 just addressed.

Scope, from the Codex review on PR #2 and the follow-up investigation:
shared/http.py:api_get returns response.json() with no decode guard, so an
HTTP 200 carrying HTML raises json.JSONDecodeError — a ValueError subclass
— straight into the `except ValueError -> INVALID_INPUT` arms of seven
modules. The guard cannot simply be added at the source: httpx.DecodingError
is an HTTPError but NOT an HTTPStatusError, and 5 of 24 modules
(bank_of_canada, ckan, ircc, ontario, recalls) catch only HTTPStatusError,
so a naive central fix converts a mislabelled error into an unhandled one.
Handler shape must be normalized first, then the guard added.

Corrects the earlier STATE.md note that called this a one-line api_get fix.
@mariomeyer
mariomeyer merged commit 98dee90 into main Jul 26, 2026
4 checks passed
mariomeyer added a commit that referenced this pull request Jul 26, 2026
PR #2 merged as 98dee90 with all four CI gates green on Python 3.12 and 3.13.
State pointer moves to Phase 20.2 (normalize tool error handling).
@mariomeyer
mariomeyer deleted the gsd/phase-20.1-integration-de-masking branch July 26, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant