Skip to content

feat: Add Land Information New Zealand (LINZ) integration#420

Open
sumitramanga wants to merge 17 commits into
masterfrom
sm/add-linz-integration
Open

feat: Add Land Information New Zealand (LINZ) integration#420
sumitramanga wants to merge 17 commits into
masterfrom
sm/add-linz-integration

Conversation

@sumitramanga

@sumitramanga sumitramanga commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Description 📝

  • Purpose: Adds a new LINZ (Land Information New Zealand) integration that reads NZ property, title, ownership, and parcel data from the LINZ Data Service (LDS) WFS API.
  • Approach: Implements a new linz integration module using the autohive-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 headline find_multi_property_owners action. XML exception reports from LDS are parsed to surface clear, actionable error messages (including licence hints for the personal data layer).

Type of change

  • New feature (non-breaking change which adds functionality)

Updates
👉 Adds linz/ integration module with 6 actions: list_available_layers, find_multi_property_owners, search_property_titles, get_title_owners, search_parcels, and query_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.md and .env.example with LINZ integration entry and required env vars

Screenshots 📷

Please lmk if you'd like to see the results

Test plan 🧪

Unit tests (no network required):

python -m pytest linz/tests/test_linz_unit.py -q

Integration tests (requires a LINZ Data Service API key):

LINZ_API_KEY=... pytest linz/tests/test_linz_integration.py -m "integration and not destructive"
  • Ownership layer tests (layer-50805) skip automatically if the key lacks the LINZ Licence for Personal Data
  • Optional: set LINZ_TEST_TITLE_NO to exercise get_title_owners on a known title
  • Optional: set LINZ_TEST_LAND_DISTRICT to scope multi-property-owner tests (defaults to Otago)

Author(s) to check 👓

  • Project and all contained modules builds successfully
  • Self-/dev-tested
  • Unit/UI/Automation/Integration tests provided where applicable
  • Code is written to standards
  • Appropriate documentation written (code comments, internal docs)

sumitramanga and others added 7 commits June 30, 2026 13:28
Needs to be swapped with one by design
- 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>
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

🔍 Integration Validation Results

Commit: a170085d1a50ef87575696908c6d6a5e714a982f · fix(linz): bound user-supplied limit and start_index inputs
Updated: 2026-07-17T04:13:37Z

Changed directories: linz

Check Result
Structure ✅ Passed
Code ✅ Passed
Tests ✅ Passed
README ✅ Passed
Version ✅ Passed
✅ Structure Check output
Validating 1 integration(s)...

============================================================
Integration: linz
============================================================
✅ All checks passed!

============================================================
SUMMARY
============================================================
Integrations validated: 1
Total errors: 0
Total warnings: 0

✅ All validations passed!
✅ Code Check output
----------------------------------------
Checking: linz
----------------------------------------

📦 Installing dependencies...

🐍 Checking Python syntax...
   ✅ Syntax OK

📥 Checking imports...
   ✅ Imports OK

📄 Checking JSON files...
   ✅ JSON files OK

🔍 Linting with ruff...
   ✅ Lint OK

🎨 Checking formatting with ruff...
   ✅ Formatting OK

🔒 Scanning for security issues with bandit...
   ✅ Security OK

🛡️ Checking dependencies for vulnerabilities with pip-audit...
   ✅ Dependencies OK

🔗 Checking config-code sync...
   ✅ Config-code sync OK

🔄 Checking fetch patterns...
   ✅ Fetch patterns OK

========================================
✅ CODE CHECK PASSED
========================================
✅ Tests Check output

Integration   Tests  Coverage        Status
-------------------------------------------
linz     72/72       95%      ✅ Passed
-------------------------------------------
Total    72/72            ✅ All passed

✅ Tests passed: linz
✅ README Check output
========================================
✅ README CHECK PASSED
========================================
✅ Version Check output
✅ linz: New integration with version 1.0.0

========================================
✅ VERSION CHECK PASSED
========================================

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>

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

Comment thread linz/linz.py
Comment thread linz/tests/conftest.py Outdated
sumitramanga and others added 4 commits July 14, 2026 13:58
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>
@sumitramanga

Copy link
Copy Markdown
Contributor Author

Getting Wayne to fix up the icon

Comment thread linz/tests/test_linz_integration.py
Comment thread linz/tests/test_linz_integration.py
Comment thread linz/requirements.txt
…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 TheRealAgentK left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread linz/linz.py Outdated
"type": props.get("type"),
"status": props.get("status"),
}
names = _split_owners(props.get("owners"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🚫 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:

  1. layer-50805 — NZ Property Titles Including Owners: one title/spatial row with a presentation-oriented aggregated owners text field. Convenient for display, but not safely normalisable because names are unescaped.
  2. 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.
  3. table-51564 — NZ Property Title Owners List: the normalised owner dataset with id, tte_id, title_no, owner_type, prime_surname, prime_other_names, and corporate_name. It relates through table-51566 — NZ Property Title Estates List (owners.tte_id -> estates.id) and then to titles by title_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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I got Claude to validate this by looking at external resources and had it apply changes.

Comment thread linz/linz.py
Comment thread linz/linz.py Outdated
start_index += page_size
if len(collected) >= max_records:
# There may be more matches than we scanned.
truncated = True

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ 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.

Comment thread linz/linz.py Outdated
layer = inputs["layer"] # required by schema

include_geometry = bool(inputs.get("include_geometry"))
limit = inputs.get("limit") or 100

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment thread linz/linz.py
if srs_name:
params["srsName"] = srs_name

response = await context.fetch(url, method="GET", params=params)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment thread linz/linz.py
return "unknown" in low and ("feature type" in low or "typename" in low or "layer-" in low)


def _check_wfs_response(response: Any) -> None:

@TheRealAgentK TheRealAgentK Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@sumitramanga

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

sumitramanga and others added 3 commits July 17, 2026 14:41
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>
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.

2 participants