feat: Add Land Information New Zealand (LINZ) integration#420
feat: Add Land Information New Zealand (LINZ) integration#420sumitramanga wants to merge 17 commits into
Conversation
Needs to be swapped with one by design
…API key has access to
- List LINZ in the repo-level README integrations index - Unit tests: nested platform-auth contract test, find_multi_property_owners request verification, query_layer error path - Integration tests: chained search -> get_title_owners test that needs no pre-provisioned title number Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🔍 Integration Validation ResultsCommit: Changed directories:
✅ Structure Check output✅ Code Check output✅ Tests Check output✅ README Check output✅ Version Check output |
SDK 2.0.1 validates context.auth as {"auth_type": ..., "credentials":
{...}} before handlers run and rejects flat auth. Update the unit conftest,
the live_context fixture, and the auth contract tests; add a regression
test that flat auth now yields a VALIDATION_ERROR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b8c93b1a3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Prepending linz/ made linz.py resolve as a top-level 'linz' module, shadowing the linz package so 'from linz.linz import ...' failed with "No module named 'linz.linz'; 'linz' is not a package" when tests ran from the repo root. Insert the repo root instead, matching the package-style imports the test files use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layer 50805 (lds.titles_plus) has no number_owners column — the field is only on layer 50804 (lds.titles) — so it was always null in get_title_owners and find_multi_property_owners responses. Confirmed against live data. The owners array already carries the parsed names, so no information is lost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Getting Wayne to fix up the icon |
…README Users hitting the ownership-layer licence wall now see where to apply: the API key help_text in config.json and step 4 of the README auth section both link the LINZ Licence for Personal Data application page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TheRealAgentK
left a comment
There was a problem hiding this comment.
Integration engineering charter review completed. Mechanical validation is green; see the five targeted comments for the SDK 2.0.2 credential/error-path follow-up, licensed personal-data confirmation, owner-layer modelling, generic query safety, and truncation accuracy.
| "type": props.get("type"), | ||
| "status": props.get("status"), | ||
| } | ||
| names = _split_owners(props.get("owners")) |
There was a problem hiding this comment.
🚫 Potential correctness blocker — please verify the owner source and avoid parsing an aggregated display field if possible.
I verified that historical layer-50805 does have an aggregated owners text field, so the earlier claim that this field does not exist was not correct. However, LINZ constructs it using string_agg(DISTINCT owner, ', ' ORDER BY owner) over unescaped free-text personal/corporate names. A comma inside a real name is therefore indistinguishable from the separator. Splitting on "," can invent owners and then report false property_count values in the headline workflow.
The LINZ data model exposes three relevant shapes:
layer-50805— NZ Property Titles Including Owners: one title/spatial row with a presentation-oriented aggregatedownerstext field. Convenient for display, but not safely normalisable because names are unescaped.layer-50806— NZ Property Title Owners: a spatial, denormalised dataset with one row per distinct(owner, title_no)and duplicated title geometry. This avoids splitting the aggregate string.table-51564— NZ Property Title Owners List: the normalised owner dataset withid,tte_id,title_no,owner_type,prime_surname,prime_other_names, andcorporate_name. It relates throughtable-51566— NZ Property Title Estates List (owners.tte_id -> estates.id) and then to titles bytitle_no.
So table-50806 in the current config/docs is not the normalised table identifier: 50806 is a layer, while the normalised owner table is 51564.
I do not know whether the configured API key has access to all of these datasets. Please check the actual licensed GetCapabilities/DescribeFeatureType response. If 50806 or 51564 is available, use a row-oriented source for aggregation. If only 50805 is available, please document that constraint and determine a correctness-safe approach rather than treating comma splitting as reliable. A sanitized real response/schema fixture would make the decision verifiable.
There was a problem hiding this comment.
This was an Amp finding in which it also did research the LINZ layer structure. I haven't validated this in person - but it seems to be very convinced here.
There was a problem hiding this comment.
I got Claude to validate this by looking at external resources and had it apply changes.
| start_index += page_size | ||
| if len(collected) >= max_records: | ||
| # There may be more matches than we scanned. | ||
| truncated = True |
There was a problem hiding this comment.
truncated is incorrect when the result count exactly equals the cap.
A full page reaching max_records does not prove another matching record exists. For example, exactly 2,000 matches with a 2,000 cap are currently reported as truncated. The unit test with exactly two 1,000-row pages codifies this false positive.
Please use a reliable numeric numberMatched when available, or request/collect one sentinel record beyond the cap and discard it. Add separate exact-cap and cap-plus-one tests so truncated only means data was actually omitted.
| layer = inputs["layer"] # required by schema | ||
|
|
||
| include_geometry = bool(inputs.get("include_geometry")) | ||
| limit = inputs.get("limit") or 100 |
There was a problem hiding this comment.
🚫 Potential safety/scope issue — please review unrestricted query_layer.
This action accepts any layer/table, does not require a filter, and passes limit directly to WFS without a runtime or schema maximum. A caller can therefore request layer-50805 without CQL, ask for an arbitrarily large number of licensed owner records, include large geometries, or query datasets that were never intentionally reviewed as Autohive workflows. That bypasses the 10,000-record safety cap used by find_multi_property_owners and creates avoidable privacy, output-size, memory, and timeout risk.
Please consider removing the generic escape hatch in favour of explicit workflow actions. If it needs to remain, enforce a server-side cap regardless of input, add schema minimum/maximum constraints, reject negative start_index, require a meaningful filter, and consider blocking or separately gating licensed ownership datasets. The other user-supplied limit fields should be bounded as well.
| if srs_name: | ||
| params["srsName"] = srs_name | ||
|
|
||
| response = await context.fetch(url, method="GET", params=params) |
There was a problem hiding this comment.
🚫 Blocker — the SDK can log the LINZ API key and owner-name filters.
LINZ requires the API key in the URL path, and this request passes that credential-bearing URL to context.fetch(). The current SDK appends params to the URL before making the request, so CQL filters containing searched owner names are also part of that URL. On an unexpected error, SDK 2.0.1 logs the complete method and URL. This can expose both services;key=<secret>/wfs and personal owner-name search terms in logs.
There is a second runtime mismatch here: production context.fetch() raises HTTPError for non-2xx responses, so _check_wfs_response() never receives the 400/403 FetchResponse that it expects. The custom integration-test fetch currently returns FetchResponse for non-2xx responses, which hides this difference. The intended licence/error mapping therefore does not run through the actual SDK path, and the surrounding ActionError(message=str(e)) handlers can expose raw exception details.
This needs an SDK-level fix because the integration cannot redact a URL after fetch() has logged it. Ninos and Kai will look at an SDK solution for 2.0.2, covering URL redaction and a safe HTTP-error contract. Once available, please update this integration to use that contract and add a regression test with a sentinel API key/owner name proving neither appears in logs or ActionError.
There was a problem hiding this comment.
We've started to have a look into this and we'll need to trace what the flow is here first. This has an additional complication in that LINZ seem to return an XML blob with an error and their XML contains the API key as well.
So, as a stop-gap, the complete XML (=the full response) might have to be suppressed here.
FYI @NinosMan
| return "unknown" in low and ("feature type" in low or "typename" in low or "layer-" in low) | ||
|
|
||
|
|
||
| def _check_wfs_response(response: Any) -> None: |
There was a problem hiding this comment.
There are a few more concerns here.
With the way the integration uses the SDK via .fetch it subscribes to the built-in error handling, returning ActionResult and ActionError or HTTPError types.
But in here we add its own error handling on top of that - in some instances for the same cases (400s) - and we're not sure those might be reached. The choice of RuntimeError is interesting - would it make more sense to use any of the SDK error types?
It seems that every actual action handler is then in a global (for it) try/catch and there'll be ActionError types wrapped around everything.
Could this maybe all be simplified by this integration NOT using the SDK fetch mechanism but custom aiohttp calls? My thinking here would be that you then have full control over what gets exposed (URLs with API keys) and can filter it all out yourself on the integration level? And this would avoid having to patch the SDK then, too.
We have precedence cases for integrations going down that path (custom aiohttp) in case of "weird" situations similar to this.
A full final page reaching max_titles_scanned doesn't prove more matches exist, so exactly cap-many matches were falsely reported as truncated. _wfs_collect now prefers the server's numeric numberMatched and otherwise probes one record past the cap, discarding it. Adds exact-cap and cap-plus-one tests for both the numeric-total and unknown-total paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…litting LINZ builds layer-50805's owners field with string_agg(DISTINCT owner, ', ') over unescaped free-text names, so a comma inside a real name is indistinguishable from the separator — splitting it can invent owners and skew property_count in the headline workflow. find_multi_property_owners now scans layer-50806 (NZ Property Title Owners, one row per owner/title pair) with geometry excluded via propertyName, and enriches estate details from layer-50805 in chunked title_no IN lookups (new include_title_details input, default true). get_title_owners resolves owners from the normalised table-51564 and adds owner_details/owners_exact, splitting the display string only as a flagged fallback. list_available_layers now diagnoses all five datasets, and the incorrect 'table-50806' example is now table-51564 (50806 is a layer; the normalised owners table is 51564). Verified against the live LDS API: schemas via DescribeFeatureType, key access via GetCapabilities, and the full integration test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
query_layer (and the other list actions) passed limit straight to WFS with no schema or runtime maximum and accepted a negative start_index, allowing arbitrarily large licensed-data responses in one request. Cap limit at 1000 per request (2000 for list_available_layers) in both the input schemas and at runtime via _bounded_limit, and clamp start_index to >= 0 via _bounded_start_index. The escape hatch stays and ownership datasets remain queryable: the per-user API key and LINZ Personal Data Licence are the access boundary, now documented in the action description and README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Description 📝
linzintegration module using theautohive-integrations-sdk, querying the LDS WFS API via HTTP. Auth is handled via a per-user LINZ Data Service API key (custom auth). A paginated scan-and-aggregate pattern powers the headlinefind_multi_property_ownersaction. XML exception reports from LDS are parsed to surface clear, actionable error messages (including licence hints for the personal data layer).Type of change
Updates
👉 Adds
linz/integration module with 6 actions:list_available_layers,find_multi_property_owners,search_property_titles,get_title_owners,search_parcels, andquery_layer👉 Adds unit tests (mocked WFS) and integration tests (real LDS API, read-only) with graceful skipping when the key lacks the Personal Data Licence for
layer-50805👉 Updates repo-level
README.mdand.env.examplewith LINZ integration entry and required env varsScreenshots 📷
Please lmk if you'd like to see the results
Test plan 🧪
Unit tests (no network required):
Integration tests (requires a LINZ Data Service API key):
LINZ_API_KEY=... pytest linz/tests/test_linz_integration.py -m "integration and not destructive"layer-50805) skip automatically if the key lacks the LINZ Licence for Personal DataLINZ_TEST_TITLE_NOto exerciseget_title_ownerson a known titleLINZ_TEST_LAND_DISTRICTto scope multi-property-owner tests (defaults toOtago)Author(s) to check 👓