Skip to content

Phase 21: New Brunswick — 22 tools, and three guarantees that weren't - #6

Merged
mariomeyer merged 59 commits into
mainfrom
gsd/phase-21-new-brunswick-government-open-data
Jul 31, 2026
Merged

Phase 21: New Brunswick — 22 tools, and three guarantees that weren't#6
mariomeyer merged 59 commits into
mainfrom
gsd/phase-21-new-brunswick-government-open-data

Conversation

@mariomeyer

Copy link
Copy Markdown
Member

Ships the new_brunswick module — 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.

Surface Tools Notes
Federal CKAN discovery 5 open.canada.ca filtered to organization:nb — NB has no provincial CKAN (221 datasets)
gnb.socrata.com discovery 2 NB's provincial Socrata portal, keyless, 312 datasets — added mid-phase, see below
GeoNB service discovery 3 The ArcGIS Hub returns 401, so the REST directory is enumerated instead
Curated geospatial 9 Flood/water, Crown land, parcels/civic address, health/education
511 transport 3 Key-gated stubs returning a structured NOT_CONFIGURED envelope (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.ca into 21-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.md asserts 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:

GET gnb.socrata.com/api/catalog/v1?domains=gnb.socrata.com&only=datasets  -> 200, resultSetSize 312
GET gnb.socrata.com/resource/4zbh-z2ij.json?$limit=2                      -> 200, real bilingual rows

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), and nb_get_provincial_parks / nb_get_mineral_occurrences drop to the long tail to hold the tool budget — both still reachable via nb_query_geonb_layer.

What CONTEXT.md got right and this does not overturn: data.gnb.ca, opendata.gnb.ca and nbopendata.ca genuinely 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:

ALL_NB_TOOLS: tuple[str, ...] = ALL_NB_TOOL_NAMES   # "cross-checked in tests so they can never drift"

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. TestManifestMatchesShippedSurface now proves genuine bidirectional set-equality between the declared manifest and the shipped @tool objects, and was validated by decoy injection.

3. A guard that existed but did not guard

The sharpest one. FILTER_REQUIRED_TOOLS is 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:

if any(filters): return                    # "%" and " " are both truthy
return value.replace("'", "''")            # escapes quotes, not LIKE metacharacters

So county="%" built UPPER(COUNTY) LIKE '%%%' and matched the entire layer. Fixed in 84192e3; the clause now escapes %, _ and \ and declares ESCAPE '\', and vacuous filters are rejected at both tool and client layers. Verified live against GeoNB_SNB_Parcels:

Input Clause Rows
county="%" (pre-fix) LIKE '%%%' 604,520
county="%" (post-fix) LIKE '%\%%' ESCAPE '\' 0
county="YORK" LIKE '%YORK%' ESCAPE '\' 64,208
county=" " / "" INVALID_INPUT, no network call

The 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 fq conjunction, unclamped limit echo, unvalidated negative limit, weak naming guard, dead schemas.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 covered nb_query_gnb_socrata_dataset's raw SoQL where/select passthrough. 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.com facts — 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 --check passes). 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 stale Current focus).

Gates

3550 passed, 2 skipped; coverage 97.37% (≥95% required); ruff clean; pyright 0 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.lock diff 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 outside shared/http.py, catch-all coverage on every tool.)

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.
@cursor

cursor Bot commented Jul 31, 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: 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".

Comment on lines +233 to +234
if extra_fq:
return f"({NB_ORG_FQ}) AND ({extra_fq})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +314 to +320
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce the GeoNB record cap

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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +694 to +697
if limit > MAX_RECORDS:
raise InvalidInput(
f"limit must be at most {MAX_RECORDS} for gnb.socrata.com queries, got {limit}"
)

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 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +1099 to +1100
out_fields="Status_E,Status_F,FileOpenDate,PidType_E,PidType_F",
include_geometry=False,

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 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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].

Comment on lines +1188 to +1189
out_fields="CIVIC_NUM,STREET,ST_TYPE_E,ST_TYPE_F,COMMUNITY",
include_geometry=False,

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 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.
@mariomeyer

Copy link
Copy Markdown
Member Author

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.

# Sev Finding Commit
F1 P1 extra_fq delimiter-breaking escaped the NB scope 264449c
F2 P1 GeoNB limit never clamped (MAX_RECORDS was a default, not a bound) 3ecf46f
F3 P2 Socrata limit lower bound unvalidated 193c091
F4 P2 Contaminated sites returned no coordinates 6e81ebe
F5 P2 Civic addresses could not complete the documented geocoding chain 4964e77

Plus 5edda91 (live integration assertions for the F4/F5 projections) and 499a76c — one further defect my own adversarial probing of the F1 validator turned up: a whitespace-only extra_fq composed to AND ( ), which Solr answers with HTTP 409, surfacing caller-invalid input to the agent as UPSTREAM_ERROR. That is the misclassification ERR-01..ERR-07 exist to prevent, so a vacuous fragment is now treated as absent.

Two of these reopened threats that 21-SECURITY.md had recorded as closed (5660496 corrects the register):

  • T-21-04 was closed citing a test that asserted "boolean semantics, not string shape" — but it only ever fed a balanced hostile fragment, so its premise was exactly what F1 violates.
  • T-21-03 was closed partly on "MAX_RECORDS=5000 bounds every other path", which was simply false.

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%, ruff clean, pyright 0 errors, catalogue up to date. F1/F2 re-verified empirically and F3/F4/F5 re-verified against live endpoints.

@mariomeyer

Copy link
Copy Markdown
Member Author

@codex please review again after changes

@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: 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})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +586 to +589
if limit <= 0:
raise InvalidInput(
f"nb_query_dataset limit must be greater than 0, got {limit}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +104 to +106
"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"

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 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +19 to +23
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 "

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 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.
@mariomeyer

Copy link
Copy Markdown
Member Author

@codex one more pass please

@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: 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".

Comment on lines +205 to +206
"**Step 1 — Crown land parcels:**\n"
"Call `nb_get_crown_land` (optional: `holder=<integer code>` if you already have "

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 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

@mariomeyer
mariomeyer merged commit 51404ce into main Jul 31, 2026
6 checks passed
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