Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions docs/address-record.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
66 changes: 66 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -236,6 +239,69 @@ How `api.postcode.gov.ng` behaved on the dates given, for anyone building on it.

---

<!-- docs/address-record.md -->

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

---

<!-- python/README.md -->

# ng-postcode
Expand Down
1 change: 1 addition & 0 deletions docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
94 changes: 94 additions & 0 deletions docs/schemas/address.schema.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
61 changes: 61 additions & 0 deletions mcp/tests/test_address_schema.py
Original file line number Diff line number Diff line change
@@ -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"})
1 change: 1 addition & 0 deletions scripts/llms_full.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading