An address is checked against the rules of its own country #147

Closed
opened 2026-09-09 10:53:20 +00:00 by tiagoagueda · 2 comments
Owner

Observation

another internet plugin, multiple postal address, not unique across instance, validades is
made on a contry basis inside the plugin

Extends #92, which specifies the model and already settles not unique across the instance
— unique per owner, freely shared between accounts, because two people at one address is a
household and not a mistake. What this adds is the country-by-country rules and where they
live.

What exists

No postal address anywhere. Profile.location is a 120-character line whose help text says
what it is for — "City and country, as it should appear on a CV" — a display string, not an
address, and right to be one.

#92 proposes the parts every format agrees on — address line, postcode, municipality, region,
country code — and already says formats differ:

address formats differ by country — where the postcode goes, whether a region is named
at all — so the model stores the parts and the rendering decides the order, per country, or
falls back to the order they were entered in.

It also fixes what valid may mean, and the reasoning is inherited from phones.py:

Deciding whether an address exists needs a per-country reference database or a paid lookup
service […] Nor is it going to post anything. Valid here can only mean well-formed enough
to be used, and an address somebody types oddly must still be saved exactly as typed.

A country list already exists and should not be built twice. phones.COUNTRIES holds ISO
3166-1 alpha-2 with English names, assets/flags.txt and the {% flag %} tag draw them, and
phones.default_country() guesses from the person's language. A country chooser is a solved
problem here.

What this asks for

Per-country rules — which fields are required, what a postcode looks like, what each field is
called, and what order they print in — held by the plugin rather than by the application.

Worth being careful about

Naming the fields is a harder problem than validating them, and it is not a translation
problem.
A person reading Postulo in Portuguese who enters a United States address should
see State; entering a Portuguese one, Distrito; a Japanese one, Prefecture. The label
depends on the address's country, not on the reader's language, and both vary at once.
Nothing in this codebase has that shape yet: every string so far is chosen by the reader's
language alone. Whatever data set is used has to carry the label keys, and the interface has
to be able to say them in the reader's language.

Several countries have no postcode at all, and several acquired one recently — Ireland's
Eircode arrived in 2015. A required-field rule that assumes a postcode exists produces a form
somebody cannot complete for the place they actually live.

Refusing to save is the failure mode to design against. phones.py keeps an unparseable
number exactly as typed, and the same must hold here: BFPO addresses, rural routes, informal
settlements, temporary accommodation, and simply a data set that is wrong about somewhere.
The rules warn; they never prevent somebody recording where they live. That is a matter
of dignity as much as correctness — an application that will not accept your address is
telling you something about who it was written for.

Where the rules come from, and the one that is ruled out.

Google's libaddressinput is the set everybody reaches for — required fields, validation and
form layout per country, the metadata Chromium's autofill uses — and it is not the answer
here
. The maintainer's position is that Postulo should not depend on Google code, and it is
the right call for a project whose 0.3.0 notes say it exists as an alternative to services
answering to somebody else's jurisdiction. Recording it so the next person does not propose
it again: the data is also served from a Google endpoint, which Postulo could not call at
render time anyway.

The problem splits in two, and only one half has a ready answer.

Layout is solved, cleanly and elsewhere. OpenCage's
address-formatting is MIT, covers 251
territories
with both templates and test cases, and does exactly one thing: renders the
parts of an address in the right order for its country. It explicitly does not validate,
and postcode-format validation is on its roadmap rather than in it. For the half of this
issue that is "where does the postcode go, is a region named at all", it is complete, it is
data rather than code, and it carries no dependency worth the name.

Required fields, postcode shapes and field labels have no obvious non-Google source, and
the honest options are worse than "pick a library":

  • National postal operators and the UPU's S42 addressing standard are authoritative and the
    UPU's data products are commercial. Individual operators publish their own formats, in
    their own words, in their own places.
  • Community postcode-regex collections exist in quantity, with licences and quality that
    vary per file and per country.
  • Wikidata is CC0 and Postulo already speaks to it for company identifiers (#42), but
    P281 is the postcode of a place, not the format for a country — whatever format data
    exists there needs its coverage checked before it is relied on.

Which points at the answer this codebase already uses everywhere else: a curated table.
phones.COUNTRIES is 250-odd hand-written rows with a comment explaining why the names are
in English. NATIVE_NAMES, PLURAL_FORMS and FLAG_COUNTRIES are the same shape, each
entry decided rather than derived, several carrying a note about the trap in that particular
language. An address-rules table maintained the same way needs no third-party licence, no
vendored snapshot and no update pipeline, and it can start with the countries whose languages
Postulo already speaks and grow the way the languages did — deliberately, in order, with the
reasoning per entry.

It also fails in the right direction. A country with no rules yet gets no rules, which means
free-form entry and no refusal — which is what this issue requires anyway.

Whatever is vendored records when it was taken, exactly as #140 concludes for NACE: a
data file with its version, not a dict in a module.

"Inside the plugin" means the plugin ships a data file, which makes it the largest thing
the plugin owns — larger than its models. That runs into #128 (what happens to a plugin's
data when the plugin goes), #127 (a built-in plugin's own catalogues, and here the label
strings are the awkward ones) and #129.

Postal is the channel that proves the contract in #146 needs a third state. It can be
validated and it cannot be confirmed, short of posting something to it — which is what some
services do and this project will not. Validated-but-unconfirmable has to be a legitimate
answer, and this is the issue that makes it concrete.

#92 already routes the privacy question correctly — a home address belongs in
docs/THREAT-MODEL.md, and Profile.location keeps printing a city and country on a CV and
never a street. None of that changes here; it is worth not undoing by accident when the
rendering moves into a plugin.

## Observation > another internet plugin, multiple postal address, not unique across instance, validades is > made on a contry basis inside the plugin Extends #92, which specifies the model and already settles *not unique across the instance* — unique per owner, freely shared between accounts, because two people at one address is a household and not a mistake. What this adds is the country-by-country rules and where they live. ## What exists No postal address anywhere. `Profile.location` is a 120-character line whose help text says what it is for — *"City and country, as it should appear on a CV"* — a display string, not an address, and right to be one. #92 proposes the parts every format agrees on — address line, postcode, municipality, region, country code — and already says formats differ: > address **formats** differ by country — where the postcode goes, whether a region is named > at all — so the model stores the parts and the rendering decides the order, per country, or > falls back to the order they were entered in. It also fixes what *valid* may mean, and the reasoning is inherited from `phones.py`: > Deciding whether an address exists needs a per-country reference database or a paid lookup > service […] Nor is it going to post anything. Valid here can only mean well-formed enough > to be used, and an address somebody types oddly must still be saved exactly as typed. **A country list already exists and should not be built twice.** `phones.COUNTRIES` holds ISO 3166-1 alpha-2 with English names, `assets/flags.txt` and the `{% flag %}` tag draw them, and `phones.default_country()` guesses from the person's language. A country chooser is a solved problem here. ## What this asks for Per-country rules — which fields are required, what a postcode looks like, what each field is called, and what order they print in — held by the plugin rather than by the application. ## Worth being careful about **Naming the fields is a harder problem than validating them, and it is not a translation problem.** A person reading Postulo in Portuguese who enters a United States address should see *State*; entering a Portuguese one, *Distrito*; a Japanese one, *Prefecture*. The label depends on **the address's country**, not on the reader's language, and both vary at once. Nothing in this codebase has that shape yet: every string so far is chosen by the reader's language alone. Whatever data set is used has to carry the label keys, and the interface has to be able to say them in the reader's language. **Several countries have no postcode at all**, and several acquired one recently — Ireland's Eircode arrived in 2015. A required-field rule that assumes a postcode exists produces a form somebody cannot complete for the place they actually live. **Refusing to save is the failure mode to design against.** `phones.py` keeps an unparseable number exactly as typed, and the same must hold here: BFPO addresses, rural routes, informal settlements, temporary accommodation, and simply a data set that is wrong about somewhere. The rules **warn**; they never prevent somebody recording where they live. That is a matter of dignity as much as correctness — an application that will not accept your address is telling you something about who it was written for. **Where the rules come from, and the one that is ruled out.** Google's `libaddressinput` is the set everybody reaches for — required fields, validation and form layout per country, the metadata Chromium's autofill uses — and **it is not the answer here**. The maintainer's position is that Postulo should not depend on Google code, and it is the right call for a project whose 0.3.0 notes say it exists as an alternative to services answering to somebody else's jurisdiction. Recording it so the next person does not propose it again: the data is also served from a Google endpoint, which Postulo could not call at render time anyway. The problem splits in two, and only one half has a ready answer. **Layout is solved, cleanly and elsewhere.** OpenCage's [address-formatting](https://github.com/OpenCageData/address-formatting) is MIT, covers **251 territories** with both templates and test cases, and does exactly one thing: renders the parts of an address in the right order for its country. It explicitly does *not* validate, and postcode-format validation is on its roadmap rather than in it. For the half of this issue that is "where does the postcode go, is a region named at all", it is complete, it is data rather than code, and it carries no dependency worth the name. **Required fields, postcode shapes and field labels have no obvious non-Google source**, and the honest options are worse than "pick a library": - *National postal operators and the UPU's S42 addressing standard* are authoritative and the UPU's data products are commercial. Individual operators publish their own formats, in their own words, in their own places. - *Community postcode-regex collections* exist in quantity, with licences and quality that vary per file and per country. - *Wikidata* is CC0 and Postulo already speaks to it for company identifiers (#42), but `P281` is the postcode **of a place**, not the format for a country — whatever format data exists there needs its coverage checked before it is relied on. **Which points at the answer this codebase already uses everywhere else: a curated table.** `phones.COUNTRIES` is 250-odd hand-written rows with a comment explaining why the names are in English. `NATIVE_NAMES`, `PLURAL_FORMS` and `FLAG_COUNTRIES` are the same shape, each entry decided rather than derived, several carrying a note about the trap in that particular language. An address-rules table maintained the same way needs no third-party licence, no vendored snapshot and no update pipeline, and it can start with the countries whose languages Postulo already speaks and grow the way the languages did — deliberately, in order, with the reasoning per entry. It also fails in the right direction. A country with no rules yet gets no rules, which means free-form entry and no refusal — which is what this issue requires anyway. **Whatever is vendored records when it was taken**, exactly as #140 concludes for NACE: a data file with its version, not a dict in a module. **"Inside the plugin" means the plugin ships a data file**, which makes it the largest thing the plugin owns — larger than its models. That runs into #128 (what happens to a plugin's data when the plugin goes), #127 (a built-in plugin's own catalogues, and here the label strings are the awkward ones) and #129. **Postal is the channel that proves the contract in #146 needs a third state.** It can be validated and it cannot be confirmed, short of posting something to it — which is what some services do and this project will not. Validated-but-unconfirmable has to be a legitimate answer, and this is the issue that makes it concrete. **#92 already routes the privacy question correctly** — a home address belongs in `docs/THREAT-MODEL.md`, and `Profile.location` keeps printing a city and country on a CV and never a street. None of that changes here; it is worth not undoing by accident when the rendering moves into a plugin.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:53:20 +00:00
Author
Owner

Corrected: the Google data set is out. The first version of this issue recommended libaddressinput, which sits badly with a project whose own release notes give European digital sovereignty as the reason for its language order. The body now says so and says why, so it does not get proposed again.

What the research changed, rather than just removing an option:

  • Layout has a clean answer — OpenCage's address-formatting, MIT, 251 territories, templates and test cases, complete since March 2024. It renders and does not validate, which is exactly half of what this issue needs.
  • Required fields, postcode shapes and field labels have no ready non-Google source. The UPU's data is commercial; community regex collections vary per country; Wikidata's P281 is a place's postcode rather than a country's format.
  • So the recommendation is a curated table, the shape this codebase already uses for phones.COUNTRIES, PLURAL_FORMS, NATIVE_NAMES and FLAG_COUNTRIES — no licence, no snapshot, no pipeline, and it can start with the countries whose languages Postulo already speaks. A country with no rules yet gets free-form entry, which is the required behaviour regardless.
**Corrected: the Google data set is out.** The first version of this issue recommended `libaddressinput`, which sits badly with a project whose own release notes give European digital sovereignty as the reason for its language order. The body now says so and says why, so it does not get proposed again. What the research changed, rather than just removing an option: - **Layout has a clean answer** — OpenCage's `address-formatting`, MIT, 251 territories, templates and test cases, complete since March 2024. It renders and does not validate, which is exactly half of what this issue needs. - **Required fields, postcode shapes and field labels have no ready non-Google source.** The UPU's data is commercial; community regex collections vary per country; Wikidata's `P281` is a place's postcode rather than a country's format. - **So the recommendation is a curated table**, the shape this codebase already uses for `phones.COUNTRIES`, `PLURAL_FORMS`, `NATIVE_NAMES` and `FLAG_COUNTRIES` — no licence, no snapshot, no pipeline, and it can start with the countries whose languages Postulo already speaks. A country with no rules yet gets free-form entry, which is the required behaviour regardless.
Author
Owner

Both halves, and the harder one was not validation.

Naming the fields

The label depends on the address's country, not on the reader's language, and both
vary at once. Nothing in this codebase has that shape yet.

So the table names a key and the catalogue supplies the word. A Portuguese reader
entering a United States address sees Estado; the same reader entering a Portuguese one
sees Distrito; a Japanese one, Prefeitura. Sixteen label keys across region, postcode and
town — and the ones that are names rather than descriptions stay names: an Eircode is an
Eircode in Lisbon, and a CAP is a CAP in Dublin.

Where the rules come from

A curated table, which is what the issue's own analysis points at: the shape
phones.COUNTRIES, NATIVE_NAMES and PLURAL_FORMS already use — rows decided rather than
derived, reasoning per entry, no third-party licence, no vendored snapshot, no update
pipeline. 36 countries: those whose languages Postulo speaks, plus the ones its people most
often apply to. It records when it was reviewed, as #140 concludes for NACE.

libaddressinput is ruled out in the module itself, so the next person does not propose
it again — a project that exists as an alternative to services answering to somebody else's
jurisdiction does not put that jurisdiction in its address form, and a test asserts the
reasoning is still written down.

OpenCage's address-formatting is credited and not vendored. The order column is their
idea, hand-written; the file stays theirs, and the module says to read it before extending
this.

Every warning

No postcode at all, and postcodes that arrived recently. Ireland expects no Eircode —
it arrived in 2015 and plenty of addresses predate it — and that is a test rather than a
comment.

Refusing to save is the failure mode designed against. Nothing here refuses. Every rule
is a note beside the row, computed in _post_clean and never through add_error. An address
reading BFPO 123 with a postcode of not a postcode at all saves, keeps every character,
and says what Portugal usually expects. phones.py's rule for an unparseable number, applied
to somewhere somebody lives.

A country with no row gets no rules, which is also byte for byte what switching the
feature off does — the same code path rather than a second one nobody has seen, and tested as
such.

#92's privacy routing is not undone. Profile.location still prints a town and a country
and never a street; the rendering that moved into the plugin is for a letter, and the CV
header does not use it.

What is deferred, and named

The issue lists the collisions this runs into — #128 on a plugin's data, #146 on a channel
that can be validated and never confirmed. The rules table is code in the plugin's package
rather than a data file it owns, so #128 has nothing new to answer here; and postal as a
confirmable channel is #146's to settle, not this one's — nothing here claims an address is
confirmed, because nothing could.

21 tests, 22 strings in all 39 European catalogues. Shipped in fd9c6ff on 0.3.0, with
main kept level.

Both halves, and the harder one was not validation. ## Naming the fields > The label depends on **the address's country**, not on the reader's language, and both > vary at once. Nothing in this codebase has that shape yet. So the table names a **key** and the catalogue supplies the **word**. A Portuguese reader entering a United States address sees *Estado*; the same reader entering a Portuguese one sees *Distrito*; a Japanese one, *Prefeitura*. Sixteen label keys across region, postcode and town — and the ones that are names rather than descriptions stay names: an Eircode is an Eircode in Lisbon, and a CAP is a CAP in Dublin. ## Where the rules come from **A curated table**, which is what the issue's own analysis points at: the shape `phones.COUNTRIES`, `NATIVE_NAMES` and `PLURAL_FORMS` already use — rows decided rather than derived, reasoning per entry, no third-party licence, no vendored snapshot, no update pipeline. 36 countries: those whose languages Postulo speaks, plus the ones its people most often apply to. It records when it was reviewed, as #140 concludes for NACE. **`libaddressinput` is ruled out in the module itself**, so the next person does not propose it again — a project that exists as an alternative to services answering to somebody else's jurisdiction does not put that jurisdiction in its address form, and a test asserts the reasoning is still written down. **OpenCage's `address-formatting` is credited and not vendored.** The `order` column is their idea, hand-written; the file stays theirs, and the module says to read it before extending this. ## Every warning **No postcode at all, and postcodes that arrived recently.** Ireland expects no Eircode — it arrived in 2015 and plenty of addresses predate it — and that is a test rather than a comment. **Refusing to save is the failure mode designed against.** Nothing here refuses. Every rule is a note beside the row, computed in `_post_clean` and never through `add_error`. An address reading `BFPO 123` with a postcode of *not a postcode at all* saves, keeps every character, and says what Portugal usually expects. `phones.py`'s rule for an unparseable number, applied to somewhere somebody lives. **A country with no row gets no rules**, which is also byte for byte what switching the feature off does — the same code path rather than a second one nobody has seen, and tested as such. **#92's privacy routing is not undone.** `Profile.location` still prints a town and a country and never a street; the rendering that moved into the plugin is for a letter, and the CV header does not use it. ## What is deferred, and named The issue lists the collisions this runs into — #128 on a plugin's data, #146 on a channel that can be validated and never confirmed. The rules table is code in the plugin's package rather than a data file it owns, so #128 has nothing new to answer here; and *postal* as a confirmable channel is #146's to settle, not this one's — nothing here claims an address is confirmed, because nothing could. 21 tests, 22 strings in all 39 European catalogues. Shipped in `fd9c6ff` on `0.3.0`, with `main` kept level.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#147
No description provided.