Phase 21: New Brunswick — 22 tools, and three guarantees that weren't - #6
Conversation
Discussion grounded in live probes rather than inherited assumptions, which changed the phase shape materially: - NB has NO provincial open-data catalogue. data.gnb.ca, opendata.gnb.ca and nbopendata.ca all fail DNS, and GeoNB's ArcGIS Hub returns 401. CLAUDE.md's "reuse for future Socrata portals PEI/NB" note is wrong for NB — correct it when this phase ships. - The real surfaces are the federal CKAN filtered to organization:nb (221 first-party GNB datasets, CSV-heavy, with FR/EN title pairs) and GeoNB ArcGIS Server (62 services, all MapServer, zero FeatureServer). - shared/arcgis_hub.py:query_feature_service works against GeoNB MapServer unchanged — verified live. Only the discovery side needs extension, since the Hub Search API is unavailable. - Layer ids are not guessable: Crown Land's only layer is 3, not 0 — the same trap as Saskatchewan's WSA_Reservoirs layer 26. Decisions: CKAN+GeoNB discovery, extend arcgis_hub, all four domains at a mid-band 18-22 tools, NOT_CONFIGURED 511 stubs on the Manitoba pattern.
…IS Server Proves D-05 (query_feature_service works unchanged against a bare ArcGIS Server MapServer, not just ArcGIS Hub) with a live call: 25 Crown Land parcels returned via GeoNB_DNR_Crown_Land layer 3, carrying OBJECTID and HOLDER. Layer 0 does not exist on that service (RESEARCH Pitfall 1). - new_brunswick module scaffolds via __init__.py MODULE_NAME/MODULE_DESCRIPTION (FileSystemProvider auto-discovery, server.py untouched) - constants.py: tracer subset only (GeoNB base URL, Crown Land service/layer/fields) - client.py: fetch_crown_land builds WHERE server-side from typed holder int, never from a caller string; routes through cached_fetch + rate limiter - tools.py: nb_get_crown_land uses @tool + @upstream_guard(_API_NAME_GEONB) - 6 unit tests green; project-wide error-handling/classification guards green
…eration RED gate for list_arcgis_server_services / get_arcgis_server_layers (D-06) — the service-directory enumerator standing in for the unavailable GeoNB Hub Search API. Outgoing-param assertions on call_args, not just URL, per the Manitoba/Saskatchewan lesson.
…ctory enumeration Adds list_arcgis_server_services + get_arcgis_server_layers (D-06) — additive only, five existing functions untouched apart from the docstring's public function list. Both decode via decode_json (ERR-05) and accept an injected httpx_client, matching search_hub_datasets' dual-path structure.
Ran the new list_arcgis_server_services/get_arcgis_server_layers against the live geonb.snb.ca directory (62 services confirmed) plus get_layer_metadata/ get_count against every curated service from 21-RESEARCH.md's Code Examples table. All 11 CONFIRMED — no layer id correction needed for Task 4's constants.py. GeoNB_DNR_WildlifeRefuges layer 0 reconfirmed as the retired 1-record placeholder (Pitfall 3).
Applies the Plan 01 Task 2 checkpoint decision (option-a): two new nb_search_gnb_socrata_datasets / nb_query_gnb_socrata_dataset discovery tools against gnb.socrata.com join the locked federal-CKAN discovery surface (D-01 stays intact), reusing shared/socrata.py verbatim. To hold the tool budget at 22 (D-08's 18-22 band), nb_get_provincial_parks and nb_get_mineral_occurrences drop to the long tail — both remain reachable via nb_query_geonb_layer. - constants.py: federal CKAN + gnb.socrata.com + all 11 re-verified GeoNB layer ids (21-SPIKE.md, 11/11 CONFIRMED) + 511 + cache TTLs + the locked 22-name ALL_NB_TOOL_NAMES manifest - schemas.py: ~20 flat Pydantic v2 models using the exact live GeoNB field names from 21-SPIKE.md §4 - client.py: four rate limiters (federal CKAN, GeoNB, gnb.socrata.com, 511 — a fourth surface joined discovery per the checkpoint), five fully implemented private helpers (_api_get, _build_fq, _shape_dataset, _geonb_query, _511_get), fetch_crown_land unchanged, 21 locked-signature NotImplementedError stubs for Plans 02-06 - prompts.py / resources.py: import-only skeletons for Plan 03 - test scaffolds: conftest.py extended with CKAN/Socrata/GeoNB/511 fixtures, test_client.py + test_prompts_resources.py added, test_tools.py extended with manifest + placeholder tests pytest --collect-only clean on both test directories; pyright/ruff clean; project-wide error-handling guards green; coverage 97.14%; server.py untouched; no new dependency.
Documents the tracer's live result, the Task 2 checkpoint decision (option-a) and its manifest consequences, the two new shared/arcgis_hub.py function signatures, the 11/11 CONFIRMED layer-id verdicts from 21-SPIKE.md, the final ALL_NB_TOOL_NAMES list, and the locked client signatures Plans 02-06 must fill.
- fetch_search_datasets, fetch_dataset_details, fetch_query_dataset, fetch_organizations, fetch_categories on top of the locked _api_get/ _build_fq/_shape_dataset helpers, all scoped to organization:nb server-side and non-overridable (T-21-04) - _shape_dataset gains bilingual keyword flattening (title/notes fallback chain was already correct from Wave 0) - fetch_query_dataset auto-routes CSV/XLSX/XLS/JSON/GEOJSON resources through fetch_and_parse and returns a metadata-only success (never an error) for unparseable formats - TestSharedApiGetContract pins outgoing package_search/package_show params including the hostile-fragment fq case; TestShapeDatasetBilingual covers the duplicate FR/EN record pair and keyword fallback
- nb_search_datasets, nb_get_dataset_details, nb_query_dataset, nb_list_organizations, nb_list_categories — @tool + @upstream_guard, bilingual messages, no organization parameter exposed anywhere in tools.py - nb_get_dataset_details builds close-match suggestions via difflib on NOT_FOUND; nb_query_dataset returns INVALID_INPUT naming the valid resource-index range - ALL_NB_TOOLS added as tools.py's tool-name registry, aliasing constants.ALL_NB_TOOL_NAMES so the two files can never silently drift - Live-verified: nb_search_datasets and nb_list_categories(lang="fr") return real NB federal-CKAN data
…t option-a) - fetch_gnb_socrata_search / fetch_gnb_socrata_query implemented entirely on top of shared/socrata.py (search_catalog, query_dataset, shape_catalog_result) — zero new HTTP client code, dedicated _socrata_limiter and cache-key namespace so calls never share a bucket with the federal CKAN surface - nb_search_gnb_socrata_datasets / nb_query_gnb_socrata_dataset — @tool + @upstream_guard, no X-App-Token header (keyless reads verified working), limit above the module record cap rejected with INVALID_INPUT before any network call, geometry columns stripped by default (Nova Scotia precedent) - constants.ALL_NB_TOOL_NAMES confirmed set-equal to tools.ALL_NB_TOOLS at 22 entries; no new dependency (pyproject.toml/uv.lock untouched)
…ck lookups - nb_flood_risk_assessment / nb_crown_land_report / nb_property_lookup chain 3-4 distinct nb_ tools each, citing GeoNB's non-guessable layer ids and filter-required layers (wetlands/parcels/civic addresses). - nb_quick_dataset_search / nb_health_facility_finder / nb_bilingual_dataset_lookup each guide a single tool call; the bilingual lookup documents D-12's separately-published FR/EN record pairs. - Every nb_-prefixed tool name referenced resolves against the Wave 0 locked manifest (constants.ALL_NB_TOOL_NAMES) — verified live and by test; neither dropped checkpoint tool (mineral occurrences, provincial parks) is named. - TestNbPrompts (27 tests): count, roles, tool-name membership, bilingual diff.
…atic data, guides - data://nb/geonb-services: all 62 live-enumerated GeoNB services with department decode, curated tool/layer id (9 curated after the checkpoint drop), and an exclusion reason for the 18 dead/non-attribute services (33 remaining are un-curated long tail, reachable via nb_query_geonb_layer). - data://nb/counties (15, bilingual), data://nb/health-regions (Horizon + Vitalité RHAs + HEALTH_FACILITY_LAYERS dispatch), data://nb/school-districts (anglophone/francophone sectors + SCHOOL_SECTOR_LAYERS dispatch, truncated strID-style field-name warning). - docs://nb/portal-guide: canonical architecture doc — records every verified dead end (data.gnb.ca/opendata.gnb.ca/nbopendata.ca DNS failure, GeoNB Hub 401) and every live surface (federal CKAN, GeoNB bare ArcGIS Server, gnb.socrata.com 312-dataset checkpoint option-a, key-gated 511), overturning 21-CONTEXT.md's stale "no provincial catalogue" framing. - docs://nb/geonb-query-guide: 3-step discovery path, WHERE syntax, the 4 GeoNB traps (non-guessable layer ids, filter-required layers, truncated field names, coded Crown Land holder). - template://nb/flood-risk-report: 12-placeholder report skeleton. - TestNbResources (29 tests): count, zero-parameter signatures, JSON validity, 62-entry catalogue, curated-tool manifest membership, required guide strings.
…rch API
nb_list_geonb_services, nb_get_geonb_service_layers and nb_query_geonb_layer
walk GeoNB's bare ArcGIS Server REST directory (list_arcgis_server_services /
get_arcgis_server_layers, added in Wave 0) since the Hub Search API at
geonb-snb.opendata.arcgis.com returns HTTP 401. The service listing hides the
5 basemap tile services and the retired GeoNB_DNR_WildlifeRefuges placeholder
by default (each with an exclusion_reason when requested), decodes the
department from the GeoNB_{DEPT}_ prefix, and names the curated nb_get_* tool
for services that already have one. The layer listing enriches each layer
with its live record count and real field names — Crown Land's worked
example (layer 3, not 0) proves layer ids are not guessable. The generic
layer-query tool is the long-tail escape hatch that keeps all 51 un-curated
services reachable, with the where argument documented as reaching ArcGIS's
own SQL-92 parser (the trust boundary).
- src/mcp_canada/modules/new_brunswick/client.py
- src/mcp_canada/modules/new_brunswick/tools.py
- src/mcp_canada/modules/new_brunswick/__tests__/test_client.py
- src/mcp_canada/modules/new_brunswick/__tests__/test_tools.py
nb_get_flood_hazard_areas and nb_get_historical_floods ship New Brunswick's signature open-data domain against live-verified GeoNB layer ids (GeoNB_ENV_FloodHazardIndex layer 0, GeoNB_ENV_Historical_Floods layer 0 for the 2008/2018 events and layer 8 for the separately-mapped 1973 event — never layer 0 by convention). Every WHERE clause is server-built from a typed parameter with string values single-quote-escaped; the historical-floods event dispatch is guarded at both the tool layer (pre-check) and the client layer (InvalidInput as the second line of defence), matching the Alberta/Saskatchewan double-guard convention. - src/mcp_canada/modules/new_brunswick/client.py - src/mcp_canada/modules/new_brunswick/tools.py - src/mcp_canada/modules/new_brunswick/__tests__/test_client.py - src/mcp_canada/modules/new_brunswick/__tests__/test_tools.py
nb_get_wetlands rejects an unfiltered call with INVALID_INPUT before any network request (T-21-03) — GeoNB_ENV_Wetlands layer 2 holds 163,206 rows. The guard is enforced at both the tool and client layers via a data-driven _require_any_filter helper keyed off constants.FILTER_REQUIRED_TOOLS, and a test patches arcgis_hub.query_feature_service to prove it is never awaited on the unfiltered path. nb_get_contaminated_sites surfaces both the English and French status fields from GeoNB_ELG_Contaminated_Sites layer 0. Coverage holds at 97.30% across the full suite; no new dependency. - src/mcp_canada/modules/new_brunswick/client.py - src/mcp_canada/modules/new_brunswick/tools.py - src/mcp_canada/modules/new_brunswick/__tests__/test_client.py - src/mcp_canada/modules/new_brunswick/__tests__/test_tools.py
…filter-required - fetch_parcels/nb_get_parcels (GeoNB_SNB_Parcels, 604,520 rows): equality on PID, case-insensitive containment on COUNTY via new _upper_contains_clause - fetch_civic_addresses/nb_get_civic_addresses (GeoNB_DPS_Civic_Address, 373,172 rows): containment on COMMUNITY/STREET, unquoted numeric equality on CIVIC_NUM - Both reject an unfiltered call with INVALID_INPUT before any network call, double-guarded at tool and client layers via FILTER_REQUIRED_TOOLS / _require_any_filter (T-21-03), proven by not-awaited tests - Task 1 (mineral occurrences / provincial parks) is a documented no-op — constants.ALL_NB_TOOL_NAMES confirms the 21-01 checkpoint swapped both to the long tail; both remain reachable via nb_query_geonb_layer
nb_get_health_facilities and nb_get_public_schools dispatch across GeoNB_Health_Facilities (6 layers, hospital_horizon/hospital_vitalite/ after_hours_clinic/adult_residential_centre/nursing_home/pharmacy) and GeoNB_EECD_PublicSchools (anglophone/francophone) via the Wave 0 constant maps, never positional guessing. An invalid dispatch key is rejected with INVALID_INPUT (listing valid values) at both the tool and client layer, before any network call. Live-verified the six health-facility layers' actual field schemas (geonb.snb.ca ?f=json probe, 2026-07-30) beyond what 21-SPIKE.md's partial listing covered for layers 2-5: each layer publishes a different name field (Name_E for hospitals, USER_Clini, Name, Name___Nom, Pharmacy_Name), so the name= containment filter dispatches per layer via _HEALTH_FACILITY_NAME_FIELD rather than assuming Name_E exists everywhere — confirmed live that a bare Name_E filter against layer 3 400s upstream. out_fields stays "*" for health facilities since the 6 layers do not share one schema. Public schools share one field schema across both layers (strID/strDST/strNM/strAD1/strGR/strURL); district= filters strDST, whose real values are live-verified short codes (ASD-E/N/S/W, DSF-NE/NO/S). A parametrized test pins the dispatched layer id for every key of both constant maps — the assertion class that would have caught the Saskatchewan wrong-layer bug.
…grity test nb_get_road_events, nb_get_winter_road_conditions and nb_get_traffic_cameras mirror the Manitoba Five11NotConfigured pattern exactly: an unconfigured NEW_BRUNSWICK_511_KEY is a normal, deterministic outcome (D-10) — each tool catches Five11NotConfigured explicitly and returns a bilingual NOT_CONFIGURED envelope naming the env var and https://511.gnb.ca, never a raised exception. tools.py reads nothing from the environment itself (the two message constants are static strings; only client.py's _511_get reads NEW_BRUNSWICK_511_KEY) — verified live with a sentinel key that it never appears in any serialised response. @upstream_guard still covers the unconfigured branch's siblings (timeout/connect-error/HTTP 500), proven by tests/test_tool_error_handling.py's catch-all gate. Road events and winter roads cache at CACHE_TTL_LIVE; cameras cache at CACHE_TTL_META since camera locations are stable infrastructure. Orchestrator-directed addition: TestManifestMatchesShippedSurface replaces the tautological "ALL_NB_TOOLS = ALL_NB_TOOL_NAMES equals itself" framing with a genuine, falsifiable bidirectional check — every name in constants.ALL_NB_TOOL_NAMES now resolves to a real @tool object in tools.py (proven via the __fastmcp__ decorator marker, not just hasattr), and no nb_-prefixed @tool exists outside the manifest. The inaccurate ALL_NB_TOOLS comment (which claimed a cross-check that only checked count/membership/ prefix) is corrected to point at what is actually enforced. All 22 manifest names now resolve; constants.py, server.py, pyproject.toml and uv.lock are untouched.
…wick tools Adds TestNewBrunswickToolScenarios (29 scenarios against live geonb.snb.ca, open.canada.ca and gnb.socrata.com through the MCP Client layer) with a manifest-coverage meta-test binding constants.ALL_NB_TOOL_NAMES to the tools actually exercised, and fills TestNbEnvelopes/TestNbLangParam/TestNbErrorPathLang (parametrized across all 22 tools) in the module's own unit suite.
…ule noqa convention
|
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ffea247ea
ℹ️ 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".
| if extra_fq: | ||
| return f"({NB_ORG_FQ}) AND ({extra_fq})" |
There was a problem hiding this comment.
Prevent extra_fq from escaping the NB scope
Because extra_fq is inserted as raw Lucene syntax, parentheses do not guarantee the organization restriction: an input such as *:* ) OR (*:* produces (organization:nb) AND (*:* ) OR (*:*), allowing the final OR branch to match non-NB datasets. This contradicts the tool's advertised non-overridable scope, so reject delimiter-breaking fragments or construct the additional filters from structured parameters.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 264449c.
Verified the exact input: extra_fq = "*:* ) OR (*:*" composed to (organization:nb) AND (*:* ) OR (*:*) — the caller's parenthesis broke out of the wrapper rather than being reinterpreted inside it. extra_fq is a live @tool parameter on nb_search_datasets, so this was reachable.
_validate_extra_fq now rejects delimiter-breaking fragments with InvalidInput. One subtlety worth recording: the attack string has equal counts of ( and ), so a count comparison would have passed it — the validator tracks nesting depth left-to-right and rejects when depth goes negative or ends non-zero, plus an unbalanced-quote check.
This also reopened threat T-21-04 in 21-SECURITY.md, which I had closed citing a test that asserted "boolean semantics, not string shape". That test only ever fed a balanced hostile fragment, so its premise was exactly what your input violates. Register corrected.
| features, truncated = await arcgis_hub.query_feature_service( | ||
| service_url, | ||
| layer_id=layer_id, | ||
| where=where, | ||
| out_fields=out_fields, | ||
| include_geometry=include_geometry, | ||
| max_records=limit, |
There was a problem hiding this comment.
Every curated GeoNB tool forwards its caller-controlled limit here without validating or clamping it, and this assignment replaces query_feature_service's 5,000-record default rather than preserving the cap. A call such as nb_get_crown_land(limit=10000000) can therefore paginate through the entire matching layer, while zero or negative limits silently return an empty success; enforce 1 <= limit <= MAX_RECORDS before fetching.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 3ecf46f.
Verified empirically before fixing:
fetch_crown_land(limit=10_000_000) -> max_records forwarded = 10000000
fetch_crown_land(limit=0) -> max_records forwarded = 0
MAX_RECORDS was only the default value of each tool's limit parameter — never a clamp — and _geonb_query passed max_records=limit straight through, replacing query_feature_service's own default.
Guard now enforces 1 <= limit <= MAX_RECORDS centrally in _geonb_query, so every curated GeoNB tool inherits it rather than each repeating the check.
Worth noting the asymmetry this exposed: the raw escape hatch fetch_geonb_layer_features had been clamping correctly all along — only the curated tools were unbounded. This reopened T-21-03, whose mitigation text claimed "MAX_RECORDS=5000 plus the truncated flag bound every other path". Register corrected.
| if limit > MAX_RECORDS: | ||
| raise InvalidInput( | ||
| f"limit must be at most {MAX_RECORDS} for gnb.socrata.com queries, got {limit}" | ||
| ) |
There was a problem hiding this comment.
Reject nonpositive Socrata query limits
The validation only rejects values above the cap, so limit=0 or a negative value is sent to Socrata as $limit; this either becomes an upstream error for caller-invalid input or yields a misleading payload whose truncated calculation is true even with no rows. Validate the lower bound before the network call, as fetch_query_dataset already does.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 193c091. fetch_gnb_socrata_query now rejects limit <= 0 before the network call, matching fetch_query_dataset's existing lower-bound check. Verified live: nb_query_gnb_socrata_dataset(dataset_id="4zbh-z2ij", limit=0) now returns INVALID_INPUT instead of reaching Socrata.
| out_fields="Status_E,Status_F,FileOpenDate,PidType_E,PidType_F", | ||
| include_geometry=False, |
There was a problem hiding this comment.
Return coordinates for contaminated-site locations
When callers use nb_get_contaminated_sites to retrieve the mapped site locations advertised by the tool, this projection omits both Latitude and Longitude while geometry is also disabled. Those fields are present in the live layer and in NBContaminatedSite, so successful results currently contain status metadata but no way to locate the sites.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 6e81ebe. Verified Latitude/Longitude exist on the live GeoNB_ELG_Contaminated_Sites/0 layer before widening the projection, since a non-existent outFields name returns HTTP 400. Live result now returns [FileOpenDate, Latitude, Longitude, PidType_E, PidType_F, Status_E, Status_F].
| out_fields="CIVIC_NUM,STREET,ST_TYPE_E,ST_TYPE_F,COMMUNITY", | ||
| include_geometry=False, |
There was a problem hiding this comment.
Include location data in civic-address results
When an agent follows nb_get_civic_addresses' documented geocoding workflow, the result cannot resolve an address to a point or supply the county for the subsequent parcel lookup: the projection omits the layer's LATITUDE, LONGITUDE, and COUNTY fields, and geometry is disabled. Include the available location fields or stop advertising that workflow.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 4964e77, plus PID beyond what you flagged.
Verified the live GeoNB_DPS_Civic_Address/0 field list first. Added LATITUDE, LONGITUDE and PID alongside the existing COUNTY. PID matters because nb_get_parcels takes a pid filter — so the advertised address→parcel chain now resolves directly rather than only via county. Live result:
{"CIVIC_NUM": 10, "STREET": "GILLIS", "COMMUNITY": "Fredericton",
"COUNTY": "York", "PID": "75282285",
"LATITUDE": 45.91593028, "LONGITUDE": -66.62866143}NBCivicAddress schema and the tool docstring updated to match; 5edda91 adds live integration assertions for both this and the contaminated-sites projection.
_build_fq wrapped a caller-supplied extra_fq fragment in parentheses to stop it widening past the NB organization scope (T-21-04), but assumed the fragment was already a well-formed Lucene atom. A fragment with its own unbalanced parenthesis (e.g. "*:* ) OR (*:*") broke out of that wrapping and could match datasets outside organization:nb. _build_fq now validates balanced parentheses (via nesting-depth tracking, not a plain count) and balanced double quotes before composing, raising InvalidInput (-> INVALID_INPUT) otherwise. Strengthens TestBuildFq, whose existing hostile-fq test only ever fed a balanced fragment.
Curated GeoNB tools passed the caller's limit straight through to arcgis_hub.query_feature_service's max_records, which REPLACES that function's own default rather than being bounded by it — MAX_RECORDS only ever appeared as a limit default, never an enforced cap. A caller could paginate an entire layer (limit=10_000_000) or silently get an empty success (limit=0). _geonb_query now validates 1 <= limit <= MAX_RECORDS before any network call, so every curated GeoNB tool inherits the bound from one place, including the raw escape hatch fetch_geonb_layer_features whose own upper-bound-only check previously left the lower bound open.
fetch_gnb_socrata_query only validated limit > MAX_RECORDS, so limit=0 or a negative value was sent upstream as $limit — producing either an upstream error for caller-invalid input or a misleading payload whose truncated calculation is true with no rows. Adds the same limit <= 0 InvalidInput pre-check fetch_query_dataset already applies (~line 506), before any network call.
The GeoNB_ELG_Contaminated_Sites out_fields projection omitted Latitude/Longitude even though the live layer carries both (verified against geonb.snb.ca) and NBContaminatedSite in schemas.py already declared them — the tool advertised mapped site locations but returned no way to place a result on a map. Widens the projection and updates the tool docstring accordingly.
…resses The GeoNB_DPS_Civic_Address out_fields projection omitted LATITUDE, LONGITUDE and PID (COUNTY was already present), so the tool could not complete its own documented address -> point / address -> parcel geocoding workflow — no point was ever returned, and PID (the other nb_get_parcels filter, alongside county) was unreachable. Live-verified against geonb.snb.ca that the layer carries all four. Widens the projection, adds PID/LATITUDE/LONGITUDE to the NBCivicAddress schema, and updates the tool docstring to describe what the workflow now actually returns.
Strengthens the existing nb_get_contaminated_sites and nb_get_civic_addresses integration scenarios to assert the newly widened out_fields (Latitude/Longitude; LATITUDE/LONGITUDE/COUNTY/PID) are actually present in a live response, not just the previously projected fields. Both scenarios pass live against geonb.snb.ca.
|
All five Codex findings verified against the code and the live GeoNB layers, then fixed — each with a failing reproduction test first, one atomic commit each.
Plus Two of these reopened threats that
Both are the same failure mode as CR-01 earlier in this phase: a test that appears to prove a guarantee while testing the wrong thing. The security audit explicitly overrode its own grep-level short-circuit because of CR-01, and then reproduced the same class of error one level up. Three internal passes — code review, goal verification, security audit — missed what an external reviewer with no stake in the prior conclusions caught. That is recorded in the phase security doc rather than quietly patched. Gates after all fixes: 3564 passed, 2 skipped, coverage 97.38%, |
|
@codex please review again after changes |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5660496c88
ℹ️ 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".
|
|
||
| async def _fetch() -> dict[str, Any]: | ||
| try: | ||
| raw = await _api_get("package_show", {"id": dataset_id}) |
There was a problem hiding this comment.
Reject non-NB package IDs before shaping details
The earlier extra_fq fix protects only package_search; when a caller supplies the ID of any other open.canada.ca package, this unscoped package_show call returns it without checking raw["organization"]["name"] == "nb". Consequently both nb_get_dataset_details and the downstream nb_query_dataset escape the advertised non-overridable New Brunswick scope. Validate the returned organization before exposing metadata or resources.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in b5120aa. This one was the important find.
Verified live before fixing — a genuine scope escape, not theoretical:
nb_get_dataset_details(dataset_id="6059da1d-e1da-4f2b-a420-b5c2a130eeaa")
-> SUCCESS, organization = "ec", title = "Weather Radar - DPQPE"
Environment Canada data returned by a tool whose own docstring says the NB filter "CANNOT be widened".
Root cause is worth stating: the earlier extra_fq hardening (264449c) protected package_search, and the WR-01 parenthesization before it protected fq precedence — but package_show never used fq at all. Three fixes to one door while a second stood open.
fetch_dataset_details now asserts the package belongs to the NB organization (via a shared NB_ORG_NAME constant, not a second literal) and rejects otherwise with NotFound — a well-formed request for something outside this tool's universe. Missing/None organization fails closed. nb_query_dataset inherits the guard through its fetch_dataset_details call.
Verified live in both directions, since a fix that rejects everything would pass a naive test:
non-NB id via nb_get_dataset_details -> NOT_FOUND
non-NB id via nb_query_dataset -> NOT_FOUND
real NB id -> resolves, org=nb
I also swept all four CKAN _api_get call sites: lines 511/641/705 all route through _build_fq; package_show at 538 was the only unscoped one. No further doors of this class.
| if limit <= 0: | ||
| raise InvalidInput( | ||
| f"nb_query_dataset limit must be greater than 0, got {limit}" | ||
| ) |
There was a problem hiding this comment.
Enforce the record cap on parsed CKAN resources
When a caller supplies a very large positive limit, this check accepts it and rows[:limit] can place every row from a large CSV/XLSX/JSON resource into one MCP response. Unlike the GeoNB and Socrata paths, there is no MAX_RECORDS upper bound here, so an input such as limit=10000000 can produce an oversized response and exhaust an agent's context or server memory. Reject or clamp values above the module cap.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 820855a. fetch_query_dataset checked limit <= 0 (from an earlier round) but nothing capped it, so rows[:limit] could slice an entire parsed CSV/XLSX into one response. Now rejects limit > MAX_RECORDS before fetching, mirroring fetch_gnb_socrata_query and _geonb_query.
Verified live: limit=10_000_000 and limit=5001 both return INVALID_INPUT; limit=10 still works.
Swept every limit-taking client function for the same class — 14 total. Twelve already reject or clamp correctly (fetch_search_datasets and fetch_gnb_socrata_search clamp 0->1 and 10M->100, which I left alone as legitimate). fetch_query_dataset was the only real gap.
| "I'll guide you through a New Brunswick flood risk assessment in four steps. " | ||
| "**Pass the location value only as a tool argument (never as a raw WHERE clause " | ||
| "fragment) — the tools below build the filter server-side.**\n\n" |
There was a problem hiding this comment.
Provide a usable spatial path for the flood workflow
When this prompt is invoked with a location such as Fredericton, it tells the agent to pass that value as a tool argument, but nb_get_flood_hazard_areas accepts only a map-sheet identifier, nb_get_historical_floods has no location filter, and nb_get_wetlands accepts only class/status; those clients also omit geometry. The workflow therefore cannot associate the returned flood or wetland polygons with the requested location. Direct the agent through a geometry-enabled spatial query or stop presenting these calls as a location-specific assessment.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 9c24fae. Verified the signatures — no flood tool accepts a place name:
nb_get_flood_hazard_areas : [sheet, limit, lang]
nb_get_historical_floods : [event, limit, lang]
nb_get_wetlands : [wetland_class, status, limit, lang]
The prompt now opens by stating plainly that no flood-layer tool accepts a place name, and routes location resolution through nb_get_civic_addresses first — which is only viable because an earlier fix in this PR (4964e77) added LATITUDE/LONGITUDE/COUNTY/PID to that projection. Corrected in both language variants; no location parameter was invented on the flood tools.
| MODULE_DESCRIPTION = ( | ||
| "New Brunswick provincial government open data across three upstream surfaces: " | ||
| "the federal open.canada.ca CKAN catalogue filtered to organization:nb " | ||
| "(dataset discovery — NOT a provincial CKAN; data.gnb.ca/opendata.gnb.ca/nbopendata.ca " | ||
| "do not resolve), GeoNB (geonb.snb.ca) ArcGIS Server MapServer services (bare ArcGIS " |
There was a problem hiding this comment.
Include Socrata in the module discovery description
The module now has four upstream surfaces, including two tools for gnb.socrata.com, but this description still claims three and omits Socrata entirely. meta/list_modules.py returns MODULE_DESCRIPTION verbatim for source discovery, and the generated TOOLS catalogue repeats it, so agents consulting module metadata can miss the provincial Socrata catalogue even though its tools are registered. Update both the English and French descriptions.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in af307ec — and it was stale in a second way you did not flag.
Beyond omitting gnb.socrata.com, the description still advertised "minerals, parks" as curated GeoNB coverage. Both nb_get_mineral_occurrences and nb_get_provincial_parks were dropped to the long tail by the same Wave 0 checkpoint that added Socrata, so that half of the sentence was stale for exactly the same reason.
Now names four surfaces and states explicitly that minerals and provincial parks are reachable only through nb_query_geonb_layer, not a dedicated curated tool. MODULE_DESCRIPTION_FR updated to match and TOOLS.md regenerated.
fetch_dataset_details called package_show with no fq scoping, unlike package_search — orchestrator-verified live that an Environment Canada dataset (organization "ec") was returned in full by nb_get_dataset_details, escaping the NB boundary the module's docstrings advertise as un-widenable (T-21-04). nb_query_dataset inherited the same hole via its call to fetch_dataset_details. Reject a package_show result whose organization.name != NB_ORG_NAME with NotFound (-> NOT_FOUND), added a new NB_ORG_NAME constant as the single source of truth for "nb" so _build_fq and this check never drift, and fail closed on a missing/None organization key. Live-verified after the fix: the Environment Canada id above is now rejected, and a real NB dataset id still resolves successfully.
fetch_query_dataset only checked `limit <= 0` (WR-03) — nothing capped the upper bound before `rows[:limit]` sliced a parsed CSV/XLSX/JSON resource. Orchestrator-verified live: limit=10_000_000 and limit=5001 were both accepted, letting a large resource land entirely in one MCP response. Reject `limit > MAX_RECORDS` with InvalidInput before any network call, mirroring the existing upper-bound check in fetch_gnb_socrata_query. Live-verified after the fix: limit=10_000_000 against a real NB dataset now returns INVALID_INPUT naming the 5000-row cap.
MODULE_DESCRIPTION/_FR still said "three upstream surfaces" and omitted gnb.socrata.com entirely, even though the Wave 0 checkpoint added nb_search_gnb_socrata_datasets and nb_query_gnb_socrata_dataset as a fourth discovery surface. Separately (not flagged by Codex), the same text still advertised "minerals, parks" as curated GeoNB coverage even though that same checkpoint dropped nb_get_mineral_occurrences/nb_get_provincial_parks to the long tail — both are reachable only via nb_query_geonb_layer now. meta/list_modules.py returns MODULE_DESCRIPTION verbatim and the generated TOOLS.md catalogue repeats it, so both errors were agent-visible. Update MODULE_DESCRIPTION and MODULE_DESCRIPTION_FR (kept semantically equivalent) to name four surfaces and describe minerals/parks as long-tail-only, and regenerate TOOLS.md via generate_catalog.py (README.md already said "four" surfaces and needed no change).
The prompt told the agent to pass a location such as "Fredericton" through a four-step flood workflow, but no flood tool accepts a place name — nb_get_flood_hazard_areas is filtered by sheet, nb_get_historical_floods by event, nb_get_wetlands by wetland_class/status, and none of the three carries geometry, so there was no spatial path either. Civic-address resolution was listed last (Step 4), after the flood-layer calls the location was supposedly needed for. Reorder the workflow so nb_get_civic_addresses (which now returns LATITUDE/LONGITUDE/COUNTY/PID per commit 4964e77) resolves the location to a point/county FIRST, state explicitly that no flood-layer tool accepts a place name, and describe the flood tools' real filters (sheet/event/class) instead of implying a location parameter exists. Same restructuring in both language variants.
|
@codex one more pass please |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90434c169b
ℹ️ 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".
| "**Step 1 — Crown land parcels:**\n" | ||
| "Call `nb_get_crown_land` (optional: `holder=<integer code>` if you already have " |
There was a problem hiding this comment.
Make the county parameter affect the report
When this prompt is called with a county such as York, the value is only echoed in the user message; the workflow then calls nb_get_crown_land, which accepts only holder and returns no geometry, followed by province-wide mineral/forestry layer queries with no county or spatial filter. The resulting report therefore cannot be scoped to the requested county, so add an executable county-to-spatial-filter path or remove the county-specific framing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 533c69f.
Checked the live layer schemas first, because your comment offered two remedies and the data rules one of them out:
Crown Land layer 3 : OBJECTID, Shape, HOLDER, Shape_Length, Shape_Area
MineralOccurrences : Shape, URN, LAT, LON, NAME, COMMODITIE, MIN_OCCR_U, OBJECTID
Neither layer has a county attribute, so an "executable county-to-spatial-filter path" would mean adding spatial-query support — a feature, not a fix. But nb_get_parcels does take county, so rather than deleting the parameter I made it do real work.
The prompt now opens by stating plainly which layers can and cannot be scoped by county, adds a Step 1 that calls nb_get_parcels(county=) as the actual county-scoped step, and labels the Crown-land and mineral steps province-wide so the report cannot imply a scoping it did not perform. Both language variants updated; steps renumbered.
Worth noting: this is the same defect class as the flood-prompt finding you raised last round, and I fixed that one instance without sweeping for siblings — which is why this one survived. So this time I swept all six prompts for parameters that are echoed but not consumable by any named tool. Three surfaced: this one (real), the flood prompt (already fixed last round), and nb_property_lookup's civic_address — which turned out to be a false positive, since Step 2 already instructs decomposing it into nb_get_civic_addresses(community=, street=). All three now verified to route to a consuming tool in both en and fr.
Gates: 3578 passed, coverage 97.39%, ruff clean, pyright 0 errors, catalogue up to date.
Ships the
new_brunswickmodule — the 8th province — and, more usefully, three cases where a claimed guarantee and the actual code had drifted apart.The module
22 tools, 6 prompts, 7 resources. Repo now at 295 tools / 21 modules.
open.canada.cafiltered toorganization:nb— NB has no provincial CKAN (221 datasets)gnb.socrata.comdiscoveryNOT_CONFIGUREDenvelope (Manitoba precedent)Two additive functions land in
shared/arcgis_hub.py(list_arcgis_server_services,get_arcgis_server_layers) — a service-directory enumerator standing in for the unavailable Hub Search API. Purely additive; York Region, Alberta, Manitoba and Saskatchewan callers are untouched.Every curated layer id was resolved live against
geonb.snb.cainto21-SPIKE.md(62 services enumerated, 11/11 ids confirmed) rather than assumed. That discipline is not decorative: Crown Land's only layer is 3, Wetlands is 2, and the mineral-occurrences service publishes its layers out of order (0, 1, 7, 2, 3, 4, 5, 8, 6). Positional reasoning about "the first layer" is wrong twice over.Three drifts between claim and code
1. A load-bearing planning claim was false
21-CONTEXT.mdasserts New Brunswick has no provincial catalogue and no Socrata instance, and instructs the phase to remove NB from CLAUDE.md's Socrata note. Live probing at plan time found the opposite:Because MCP tool names are a published API surface, this went to a blocking checkpoint rather than a planner's judgement call. Resolution: the two Socrata tools ship (reusing
shared/socrata.py, zero new client code), andnb_get_provincial_parks/nb_get_mineral_occurrencesdrop to the long tail to hold the tool budget — both still reachable vianb_query_geonb_layer.What CONTEXT.md got right and this does not overturn:
data.gnb.ca,opendata.gnb.caandnbopendata.cagenuinely do not resolve, and the GeoNB Hub genuinely 401s.2. An invariant that could not fail
Every plan was reporting "tool manifest set-equal ✓" against:
An alias. The assertion was unfalsifiable, and the real manifest tests only checked count, membership and
nb_prefix — nothing verified that a manifest name resolved to a registered tool.TestManifestMatchesShippedSurfacenow proves genuine bidirectional set-equality between the declared manifest and the shipped@toolobjects, and was validated by decoy injection.3. A guard that existed but did not guard
The sharpest one.
FILTER_REQUIRED_TOOLSis the mitigation for threat T-21-03 — refuse an unfiltered query against the 604,520-row parcel layer before any network I/O, because the harm is an agent's context window destroyed by a well-formed request. The guard was present and greppable. It was also defeated by one character:So
county="%"builtUPPER(COUNTY) LIKE '%%%'and matched the entire layer. Fixed in84192e3; the clause now escapes%,_and\and declaresESCAPE '\', and vacuous filters are rejected at both tool and client layers. Verified live againstGeoNB_SNB_Parcels:county="%"(pre-fix)LIKE '%%%'county="%"(post-fix)LIKE '%\%%' ESCAPE '\'county="YORK"LIKE '%YORK%' ESCAPE '\'county=" "/""INVALID_INPUT, no network callThe generalisable point: a grep-depth check would have marked T-21-03 CLOSED both before and after this fix. Presence of a named guard is not evidence of a working one. The five remaining review findings (unparenthesised
fqconjunction, unclampedlimitecho, unvalidated negativelimit, weak naming guard, deadschemas.py) were fixed in the same pass.Security
21-SECURITY.md: 21 threats, 21 closed,threats_open: 0. The workflow's L1 short-circuit was deliberately overridden — its premise ("grep depth is sufficient") is exactly what #3 above disproves — so T-21-01/02/03/04/16 were traced at L2/L3 depth.The audit surfaced one register gap worth recording: the threat register covered the planned surface, not the shipped one. All seven
<threat_model>blocks were authored before the checkpoint, so nothing coverednb_query_gnb_socrata_dataset's raw SoQLwhere/selectpassthrough. Registered as T-21-20 (Tampering / medium / accept — read-only keyless public server, SoQL parser is the trust boundary), matching the already-accepted T-21-12 GeoNB escape hatch, with the boundary now named in the docstring where agents actually read it.One caveat left open honestly: T-21-08's disposition claims the cache-key prefix is "asserted in unit tests". It isn't — the autouse fixture ignores
key. The code mitigation is present at all 24 sites, so the threat is closed on code evidence, but the coverage claim is overstated and is flagged rather than quietly accepted.Documentation
CLAUDE.md now records the verified
gnb.socrata.comfacts — neither CONTEXT.md's false claim nor the original forward-looking guess — and marks PEI's portal as still requiring independent verification. README/TOOLS.md regenerated (scripts/generate_catalog.py --checkpasses). ROADMAP's Phase 21 goal was restated so its sub-counts reconcile to 22 after the checkpoint, and two stale STATE.md entries were cleared (a resolved checkpoint still reading as open, and a staleCurrent focus).Gates
3550 passed, 2 skipped; coverage 97.37% (≥95% required);
ruffclean;pyright0 errors; catalogue fresh. Live NB integration 29/29 through the MCP Client layer, asserting field presence and non-null values — not just response shape. Zero new dependencies (pyproject.toml/uv.lockdiff empty across all 45 commits).Phase verification: 30/30 must-haves, no gaps, no human-verification items.
One pre-existing live failure elsewhere in the repo (Quebec MTQ WFS) is unrelated — no commit in this phase touches any Quebec file.
Requirements: NB-01..NB-25. (ERR-01..ERR-07 belong to Phases 20.2–20.4; this module complies with them — no bare
ValueError, no JSON decoded outsideshared/http.py, catch-all coverage on every tool.)