From f5868700302dfd11a4fdc1d03905f52e03570a26 Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:09:34 +0100 Subject: [PATCH 1/2] feat(schema): add an address record around the postcode reference --- docs/schemas/address.schema.json | 94 ++++++++++++++++++++++++++++++++ mcp/tests/test_address_schema.py | 61 +++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 docs/schemas/address.schema.json create mode 100644 mcp/tests/test_address_schema.py diff --git a/docs/schemas/address.schema.json b/docs/schemas/address.schema.json new file mode 100644 index 0000000..bd02784 --- /dev/null +++ b/docs/schemas/address.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://adeniyikayodee.github.io/ng-postcode/schemas/address.schema.json", + "title": "Nigerian address record", + "description": "A Nigerian address as passed between systems: a postcode reference, with the names and lines that other address formats need. Only the postcode and the country are required. Receivers ignore fields they do not know.", + "type": "object", + "required": [ + "postcode", + "country" + ], + "properties": { + "postcode": { + "$ref": "postcode-reference.schema.json" + }, + "country": { + "description": "ISO 3166-1 alpha-2.", + "const": "NG" + }, + "state": { + "description": "State name, such as Ekiti. NIPOST's lookup returns it as state_name.", + "type": "string", + "minLength": 1 + }, + "lga": { + "description": "Local government area name. NIPOST's lookup returns it as lga_name.", + "type": "string", + "minLength": 1 + }, + "locality": { + "description": "Town or locality name, such as Ado Ekiti. NIPOST's lookup returns it as locality_name.", + "type": "string", + "minLength": 1 + }, + "street_address": { + "description": "House number and street on one line, such as 12 NTA Road.", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "What else locates the place, in the sender's words, such as behind Fabian Hotel.", + "type": "string", + "minLength": 1 + }, + "location": { + "description": "A point on the building, in WGS 84 decimal degrees.", + "type": "object", + "required": [ + "lat", + "lng" + ], + "properties": { + "lat": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "lng": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + } + } + }, + "examples": [ + { + "postcode": { + "code": "EK-01-A03-FK-01", + "level": "building", + "confidence": "high", + "assigned": true, + "checked_at": "2026-10-03T12:00:00Z" + }, + "country": "NG", + "state": "Ekiti", + "lga": "Ado Ekiti", + "locality": "Ado Ekiti", + "street_address": "12 NTA Road", + "location": { + "lat": 7.6211, + "lng": 5.2214 + } + }, + { + "postcode": { + "code": "EK-01-A03-FK", + "level": "area", + "confidence": "medium" + }, + "country": "NG", + "description": "behind Fabian Hotel, NTA Road" + } + ] +} diff --git a/mcp/tests/test_address_schema.py b/mcp/tests/test_address_schema.py new file mode 100644 index 0000000..5740cac --- /dev/null +++ b/mcp/tests/test_address_schema.py @@ -0,0 +1,61 @@ +"""The shared address record schema, which wraps a postcode reference.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + +SCHEMAS = Path(__file__).resolve().parents[2] / "docs/schemas" + +pytestmark = pytest.mark.skipif( + not SCHEMAS.exists(), reason="the schemas live in the repository, not the sdist" +) + + +def loaded(name: str) -> dict[str, Any]: + document: dict[str, Any] = json.loads((SCHEMAS / name).read_text(encoding="utf-8")) + return document + + +@pytest.fixture(scope="module") +def schema() -> Draft202012Validator: + address = loaded("address.schema.json") + Draft202012Validator.check_schema(address) + # The reference is resolved from the file beside it, never fetched. + reference = loaded("postcode-reference.schema.json") + registry = Registry().with_resource(reference["$id"], Resource.from_contents(reference)) + return Draft202012Validator(address, registry=registry) + + +def test_its_own_examples_are_valid(schema: Draft202012Validator) -> None: + for example in loaded("address.schema.json")["examples"]: + schema.validate(example) + + +BUILDING = {"code": "EK-01-A03-FK-01", "level": "building"} + + +@pytest.mark.parametrize( + "address", + [ + {"postcode": BUILDING}, + {"country": "NG"}, + {"postcode": BUILDING, "country": "Nigeria"}, + {"postcode": "EK-01-A03-FK-01", "country": "NG"}, + {"postcode": {"code": "EK-01-A03-FK", "level": "building"}, "country": "NG"}, + {"postcode": BUILDING, "country": "NG", "state": ""}, + {"postcode": BUILDING, "country": "NG", "location": {"lat": 95, "lng": 5.2}}, + {"postcode": BUILDING, "country": "NG", "location": {"lat": 7.6}}, + ], +) +def test_rejects_malformed_addresses(schema: Draft202012Validator, address: dict[str, Any]) -> None: + assert not schema.is_valid(address) + + +def test_unknown_fields_are_ignored(schema: Draft202012Validator) -> None: + schema.validate({"postcode": BUILDING, "country": "NG", "floor": "2"}) From 793b107cdf1144a6d5da6da5b6ba73f35dde0d0b Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:11:22 +0100 Subject: [PATCH 2/2] docs(site): map the address record to ISO 20022, FHIR, schema.org, and vCard --- README.md | 2 ++ docs/address-record.md | 58 +++++++++++++++++++++++++++++++++++++ docs/index.md | 2 ++ docs/llms-full.txt | 66 ++++++++++++++++++++++++++++++++++++++++++ docs/llms.txt | 1 + scripts/llms_full.py | 1 + 6 files changed, 130 insertions(+) create mode 100644 docs/address-record.md diff --git a/README.md b/README.md index 6527db3..1b7d6b0 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ Input may be hyphenated, spaced or compact, in either case. The compact form mat [`docs/schemas/postcode-reference.schema.json`](docs/schemas/postcode-reference.schema.json) defines a small JSON object for handing a location from one system or AI agent to another: the code, its level (`state` to `building`), and optionally a confidence and whether NIPOST confirmed it. A resolved or partial `resolve_address` answer already fits it. +For a whole address, [`docs/address-record.md`](docs/address-record.md) wraps the reference in an address record and maps it onto ISO 20022, FHIR, schema.org, and vCard. + ## Design - **One behaviour, four languages:** Rust, Python, JavaScript and Java all run the cases in [`spec/`](spec), so they cannot drift apart. The Node MCP server registers its tools from [`spec/mcp.json`](spec/mcp.json), which the Python server generates. diff --git a/docs/address-record.md b/docs/address-record.md new file mode 100644 index 0000000..b167c71 --- /dev/null +++ b/docs/address-record.md @@ -0,0 +1,58 @@ +# A Nigerian address record + +A small JSON object for passing a Nigerian address between systems. It is built around the building postcode NIPOST issues, and it maps onto the address formats that payments, health, and web systems already use. + +```json +{ + "postcode": { "code": "EK-01-A03-FK-01", "level": "building", "assigned": true, "checked_at": "2026-10-03T12:00:00Z" }, + "country": "NG", + "state": "Ekiti", + "lga": "Ado Ekiti", + "locality": "Ado Ekiti", + "street_address": "12 NTA Road", + "location": { "lat": 7.6211, "lng": 5.2214 } +} +``` + +Validate a record against the [JSON Schema](schemas/address.schema.json). Only `postcode` and `country` are required, and a receiver ignores fields it does not know. + +## Fields + +| Field | Holds | Where it comes from | +| --- | --- | --- | +| `postcode` | A [postcode reference](schemas/postcode-reference.schema.json): the code, its level, and optionally a confidence and whether NIPOST confirmed it | A code the person gave, a location pin, or `resolve_address` | +| `country` | `NG` | Fixed | +| `state` | State name | `state_name` in NIPOST's lookup | +| `lga` | Local government area name | `lga_name` in NIPOST's lookup | +| `locality` | Town or locality name | `locality_name` in NIPOST's lookup | +| `street_address` | House number and street on one line | The person, or `recent_house_address` in NIPOST's lookup | +| `description` | What else locates the place, such as "behind Fabian Hotel" | The person | +| `location` | A point on the building, in WGS 84 decimal degrees | A location pin | + +NIPOST returns the three names and the house address from lookup level 2, which needs a key granted that level. These libraries hold no tables of names, so leave a name out when you have not looked it up. + +## Rules + +- **Send the code at the level you know:** an area or district code with its `level` is more useful to the receiver than a building code that was guessed. +- **Write the hyphenated form:** `EK-01-A03-FK-01` in a record or a message, and the compact `EK01A03FK01` in a database column. +- **Fill another format's postcode field only at building level:** a coarser code is a prefix of a postcode. Keep it in the record and leave the other format's field empty. +- **Treat it as personal data:** an address tied to a person is covered by the Nigeria Data Protection Act. + +## Mapping to other formats + +| Record | ISO 20022 `PstlAdr` | FHIR `Address` | schema.org `PostalAddress` | vCard `ADR` | +| --- | --- | --- | --- | --- | +| `postcode.code` | `PstCd` | `postalCode` | `postalCode` | postal code | +| `country` | `Ctry` | `country` | `addressCountry` | country name | +| `state` | `CtrySubDvsn` | `state` | `addressRegion` | region | +| `lga` | `DstrctNm` | `district` | none | none | +| `locality` | `TwnNm` | `city` | `addressLocality` | locality | +| `street_address` | `StrtNm` and `BldgNb`, or an `AdrLine` in a hybrid address | `line` | `streetAddress` | street address | +| `description` | An `AdrLine` in a hybrid address | `line` | none | none | +| `location` | none | The `geolocation` extension | `geo` on the enclosing `Place` | The separate `GEO` property | + +### Payments + +`PstCd` holds [up to 16 characters](https://www.frbservices.org/wp-content/uploads/whats-in-an-iso-20022-message-sidebar.pdf), so the 15-character hyphenated code fits as written. `TwnNm`, `DstrctNm`, and `CtrySubDvsn` hold up to 35 characters each, `StrtNm` and an `AdrLine` up to 70, and `BldgNb` up to 16. + +Swift is retiring unstructured addresses from cross-border payments in favour of structured and hybrid ones. In a hybrid address the town and the country are mandatory in their own fields, with up to two free-text lines beside them, according to [bank guidance](https://www.jpmorgan.com/insights/payments/cross-border-payments/iso-20022-migration). `locality` and `country` cover that minimum. The date, the field lengths, and the mandatory fields depend on the message version and the market, so check Swift's current notices and the usage guideline you send under. diff --git a/docs/index.md b/docs/index.md index 208ca31..9349c3e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -109,6 +109,8 @@ When one system or AI agent hands a location to another, send it as a postcode r `level` is one of `state`, `lga`, `district`, `area` or `building`, and `code` is the hyphenated code down to that level. Add `assigned` and `checked_at` after confirming a building code with NIPOST. The receiver can validate the reference against the [JSON Schema](schemas/postcode-reference.schema.json) and re-check the code itself. A resolved or partial answer from `resolve_address` already has this shape. +For a whole address, wrap the reference in an [address record](address-record.md). It adds the names and lines that other formats need, and maps onto ISO 20022, FHIR, schema.org, and vCard. + ## The format | Segment | Example | Shape | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index c6fd2ff..08b8c75 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -14,6 +14,7 @@ Since 1 October 2026 a Nigerian building's postcode is this 11-character code. A - A well-formed code is not necessarily assigned to a building; only the API confirms that. - To pass a postcode between systems or agents, use a postcode reference: `{"code": "EK-01-A03-FK", "level": "area", "confidence": "medium"}`. Schema: https://adeniyikayodee.github.io/ng-postcode/schemas/postcode-reference.schema.json +- To pass a whole address, wrap that reference in an address record with `country: "NG"` and optional `state`, `lga`, `locality`, `street_address`, `description`, and `location`. It maps onto ISO 20022, FHIR, schema.org, and vCard: https://adeniyikayodee.github.io/ng-postcode/address-record ## Packages @@ -178,6 +179,8 @@ Input may be hyphenated, spaced or compact, in either case. The compact form mat [`docs/schemas/postcode-reference.schema.json`](docs/schemas/postcode-reference.schema.json) defines a small JSON object for handing a location from one system or AI agent to another: the code, its level (`state` to `building`), and optionally a confidence and whether NIPOST confirmed it. A resolved or partial `resolve_address` answer already fits it. +For a whole address, [`docs/address-record.md`](docs/address-record.md) wraps the reference in an address record and maps it onto ISO 20022, FHIR, schema.org, and vCard. + ## Design - **One behaviour, four languages:** Rust, Python, JavaScript and Java all run the cases in [`spec/`](spec), so they cannot drift apart. The Node MCP server registers its tools from [`spec/mcp.json`](spec/mcp.json), which the Python server generates. @@ -236,6 +239,69 @@ How `api.postcode.gov.ng` behaved on the dates given, for anyone building on it. --- + + +# A Nigerian address record + +A small JSON object for passing a Nigerian address between systems. It is built around the building postcode NIPOST issues, and it maps onto the address formats that payments, health, and web systems already use. + +```json +{ + "postcode": { "code": "EK-01-A03-FK-01", "level": "building", "assigned": true, "checked_at": "2026-10-03T12:00:00Z" }, + "country": "NG", + "state": "Ekiti", + "lga": "Ado Ekiti", + "locality": "Ado Ekiti", + "street_address": "12 NTA Road", + "location": { "lat": 7.6211, "lng": 5.2214 } +} +``` + +Validate a record against the [JSON Schema](schemas/address.schema.json). Only `postcode` and `country` are required, and a receiver ignores fields it does not know. + +## Fields + +| Field | Holds | Where it comes from | +| --- | --- | --- | +| `postcode` | A [postcode reference](schemas/postcode-reference.schema.json): the code, its level, and optionally a confidence and whether NIPOST confirmed it | A code the person gave, a location pin, or `resolve_address` | +| `country` | `NG` | Fixed | +| `state` | State name | `state_name` in NIPOST's lookup | +| `lga` | Local government area name | `lga_name` in NIPOST's lookup | +| `locality` | Town or locality name | `locality_name` in NIPOST's lookup | +| `street_address` | House number and street on one line | The person, or `recent_house_address` in NIPOST's lookup | +| `description` | What else locates the place, such as "behind Fabian Hotel" | The person | +| `location` | A point on the building, in WGS 84 decimal degrees | A location pin | + +NIPOST returns the three names and the house address from lookup level 2, which needs a key granted that level. These libraries hold no tables of names, so leave a name out when you have not looked it up. + +## Rules + +- **Send the code at the level you know:** an area or district code with its `level` is more useful to the receiver than a building code that was guessed. +- **Write the hyphenated form:** `EK-01-A03-FK-01` in a record or a message, and the compact `EK01A03FK01` in a database column. +- **Fill another format's postcode field only at building level:** a coarser code is a prefix of a postcode. Keep it in the record and leave the other format's field empty. +- **Treat it as personal data:** an address tied to a person is covered by the Nigeria Data Protection Act. + +## Mapping to other formats + +| Record | ISO 20022 `PstlAdr` | FHIR `Address` | schema.org `PostalAddress` | vCard `ADR` | +| --- | --- | --- | --- | --- | +| `postcode.code` | `PstCd` | `postalCode` | `postalCode` | postal code | +| `country` | `Ctry` | `country` | `addressCountry` | country name | +| `state` | `CtrySubDvsn` | `state` | `addressRegion` | region | +| `lga` | `DstrctNm` | `district` | none | none | +| `locality` | `TwnNm` | `city` | `addressLocality` | locality | +| `street_address` | `StrtNm` and `BldgNb`, or an `AdrLine` in a hybrid address | `line` | `streetAddress` | street address | +| `description` | An `AdrLine` in a hybrid address | `line` | none | none | +| `location` | none | The `geolocation` extension | `geo` on the enclosing `Place` | The separate `GEO` property | + +### Payments + +`PstCd` holds [up to 16 characters](https://www.frbservices.org/wp-content/uploads/whats-in-an-iso-20022-message-sidebar.pdf), so the 15-character hyphenated code fits as written. `TwnNm`, `DstrctNm`, and `CtrySubDvsn` hold up to 35 characters each, `StrtNm` and an `AdrLine` up to 70, and `BldgNb` up to 16. + +Swift is retiring unstructured addresses from cross-border payments in favour of structured and hybrid ones. In a hybrid address the town and the country are mandatory in their own fields, with up to two free-text lines beside them, according to [bank guidance](https://www.jpmorgan.com/insights/payments/cross-border-payments/iso-20022-migration). `locality` and `country` cover that minimum. The date, the field lengths, and the mandatory fields depend on the message version and the market, so check Swift's current notices and the usage guideline you send under. + +--- + # ng-postcode diff --git a/docs/llms.txt b/docs/llms.txt index f371213..7ec1c1f 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -12,6 +12,7 @@ Since 1 October 2026 a Nigerian building's postcode is this 11-character code. A - A well-formed code is not necessarily assigned to a building; only the API confirms that. - To pass a postcode between systems or agents, use a postcode reference: `{"code": "EK-01-A03-FK", "level": "area", "confidence": "medium"}`. Schema: https://adeniyikayodee.github.io/ng-postcode/schemas/postcode-reference.schema.json +- To pass a whole address, wrap that reference in an address record with `country: "NG"` and optional `state`, `lga`, `locality`, `street_address`, `description`, and `location`. It maps onto ISO 20022, FHIR, schema.org, and vCard: https://adeniyikayodee.github.io/ng-postcode/address-record ## Packages diff --git a/scripts/llms_full.py b/scripts/llms_full.py index ad85c79..61b9895 100644 --- a/scripts/llms_full.py +++ b/scripts/llms_full.py @@ -15,6 +15,7 @@ "docs/llms.txt", "README.md", "docs/api-differences.md", + "docs/address-record.md", "python/README.md", "CRATE.md", "js/packages/ng-postcode-js/README.md",