Several postal addresses per account, one primary — but not unique across the instance #92

Closed
opened 2026-09-07 15:01:58 +00:00 by tiagoagueda · 4 comments
Owner

Observation

file another issue with the same criteria but with postal addresses, multiple valid ones,
one primary

Companion to #90, which does the same for telephone numbers.

What exists

There is no postal address in Postulo at all. What looks like one is not:

location = models.CharField(
    _("location"), max_length=120, blank=True,
    help_text=_("City and country, as it should appear on a CV."),
)

One free-text line, 120 characters, and its help text says exactly what it is for: the line
a CV header prints. CV.show_contact_details describes it as "Your name, email and
location, taken from your profile"
. It is a display string, not an address, and it was
right to be one.

Postulo already throws a real address away

tests/data/europass.json, which the importer added in #69 reads today:

"Address": {"Contact": {
  "AddressLine": "Rua do Exemplo 1",
  "Municipality": "Lisboa",
  "PostalCode": "1000-001",
  "Country": {"Code": "PT", "Label": "Portugal"}
}}

and resume/europass.py keeps this much of it:

person["location"] = _place(_text(address, "Municipality"), _text(address, "Country", "Label"))

The street and the postcode are discarded on import. Somebody exports a Europass CV,
imports it here, and the two lines that make an address an address are silently gone. That
is the concrete cost of not having somewhere to put them, and it is happening now.

What this asks for

A PostalAddress model owned by a person: several per account, exactly one primary, the
invariant enforced by a database constraint rather than by a form.

Where it deliberately differs from #90

No uniqueness across the instance, and this is the important difference. #90 asks for
telephone numbers to be unique platform-wide, which is defensible because a mobile number
belongs to one person. A postal address does not. Spouses share one. Flatmates share one.
An adult child living at home shares one. Two siblings on a family instance share one, and a
family instance is exactly the kind of small self-hosted deployment this project is built
for.

A uniqueness constraint here would refuse the second member of a household their own
address, and the refusal would also disclose that somebody else on the server lives there --
the same leak #90 has to reason about, but with no compensating reason to accept it.

So: unique per owner, so one person cannot list the same address twice, and freely shared
between accounts.

"Valid" cannot mean "verified". Deciding whether an address exists needs a per-country
reference database or a paid lookup service, which is a network dependency, a cost and a
stream of updates. phones.py already refuses the equivalent for telephone numbers and says
why: "Postulo has no use for the answer: it is not going to dial anything." 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.

The shape of the model

Europass gives the fields to copy, and they are the ones every format agrees on:
address line, postcode, municipality, region, country code. Beyond that, 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.

What the primary is for, and what it is not for

The primary address is what a form or a letter uses when one is needed. A formal cover
letter carries the sender's address; a job application form asks for one.

It is not for the CV header. Guidance in most of Europe is that a CV should carry a city
and country and not a street -- partly because it is irrelevant and partly because a precise
address invites the reader to draw conclusions about somebody from where they live. So
Profile.location stays as it is: its own overridable line, defaulting from the primary
address's municipality and country, never printing the street. Anything else would quietly
put people's home addresses on documents they send to strangers.

What else has to move

  • core/export.py -- PROFILE_FIELDS lists location; the new table has to be in the
    archive, or taking your data out stops being complete.
  • tests/security/ -- a new table holding home addresses gets the ownership sweep, like
    every table that holds a personal detail.
  • docs/THREAT-MODEL.md -- a home address is among the most sensitive things this
    application would hold, and the document should say so rather than have it appear quietly.
  • resume/europass.py -- import stops discarding the street and the postcode, which is
    what makes this worth doing rather than merely tidy.

Classification

Enhancement. Not breaking: Profile.location keeps working and keeps its meaning; this adds
somewhere for the parts of an address that currently have nowhere to go.

## Observation > file another issue with the same criteria but with postal addresses, multiple valid ones, > one primary Companion to #90, which does the same for telephone numbers. ## What exists There is no postal address in Postulo at all. What looks like one is not: ```python location = models.CharField( _("location"), max_length=120, blank=True, help_text=_("City and country, as it should appear on a CV."), ) ``` One free-text line, 120 characters, and its help text says exactly what it is for: the line a CV header prints. `CV.show_contact_details` describes it as *"Your name, email and location, taken from your profile"*. It is a **display string**, not an address, and it was right to be one. ## Postulo already throws a real address away `tests/data/europass.json`, which the importer added in #69 reads today: ```json "Address": {"Contact": { "AddressLine": "Rua do Exemplo 1", "Municipality": "Lisboa", "PostalCode": "1000-001", "Country": {"Code": "PT", "Label": "Portugal"} }} ``` and `resume/europass.py` keeps this much of it: ```python person["location"] = _place(_text(address, "Municipality"), _text(address, "Country", "Label")) ``` **The street and the postcode are discarded on import.** Somebody exports a Europass CV, imports it here, and the two lines that make an address an address are silently gone. That is the concrete cost of not having somewhere to put them, and it is happening now. ## What this asks for A `PostalAddress` model owned by a person: several per account, exactly one primary, the invariant enforced by a database constraint rather than by a form. ## Where it deliberately differs from #90 **No uniqueness across the instance, and this is the important difference.** #90 asks for telephone numbers to be unique platform-wide, which is defensible because a mobile number belongs to one person. A postal address does not. Spouses share one. Flatmates share one. An adult child living at home shares one. Two siblings on a family instance share one, and a family instance is exactly the kind of small self-hosted deployment this project is built for. A uniqueness constraint here would refuse the second member of a household their own address, and the refusal would also disclose that somebody else on the server lives there -- the same leak #90 has to reason about, but with no compensating reason to accept it. So: unique **per owner**, so one person cannot list the same address twice, and freely shared between accounts. **"Valid" cannot mean "verified".** Deciding whether an address exists needs a per-country reference database or a paid lookup service, which is a network dependency, a cost and a stream of updates. `phones.py` already refuses the equivalent for telephone numbers and says why: *"Postulo has no use for the answer: it is not going to dial anything."* 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. ## The shape of the model Europass gives the fields to copy, and they are the ones every format agrees on: address line, postcode, municipality, region, country code. Beyond that, 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. ## What the primary is for, and what it is not for The primary address is what a form or a letter uses when one is needed. A formal cover letter carries the sender's address; a job application form asks for one. **It is not for the CV header.** Guidance in most of Europe is that a CV should carry a city and country and not a street -- partly because it is irrelevant and partly because a precise address invites the reader to draw conclusions about somebody from where they live. So `Profile.location` stays as it is: its own overridable line, defaulting from the primary address's municipality and country, never printing the street. Anything else would quietly put people's home addresses on documents they send to strangers. ## What else has to move - `core/export.py` -- `PROFILE_FIELDS` lists `location`; the new table has to be in the archive, or taking your data out stops being complete. - `tests/security/` -- a new table holding home addresses gets the ownership sweep, like every table that holds a personal detail. - `docs/THREAT-MODEL.md` -- a home address is among the most sensitive things this application would hold, and the document should say so rather than have it appear quietly. - `resume/europass.py` -- import stops discarding the street and the postcode, which is what makes this worth doing rather than merely tidy. ## Classification Enhancement. Not breaking: `Profile.location` keeps working and keeps its meaning; this adds somewhere for the parts of an address that currently have nowhere to go.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 15:01:58 +00:00
Author
Owner

Depends on nothing, but sits beside #90 (telephone numbers): same shape, deliberately different uniqueness rule. Whichever is built first should establish the add / remove / make-primary pattern in Settings → Your details, and the other should follow it rather than invent a second one.

Depends on nothing, but sits beside #90 (telephone numbers): same shape, deliberately different uniqueness rule. Whichever is built first should establish the add / remove / make-primary pattern in *Settings → Your details*, and the other should follow it rather than invent a second one.
Author
Owner

#146 proposes one contract for every kind of contact detail — how it is shaped, whether it can be proved, and which connector proves it — with email, telephone and postal addresses as the three instances.

Postal is the one that makes the contract honest: it can be validated and cannot be confirmed, short of posting something to it. That is a legitimate answer rather than a missing feature, and this issue is the reason the contract has to allow it. Nothing here is blocked by it — this issue can be built first and described by the contract afterwards — but the not unique across the instance decision in the title is a second axis the contract should carry too, since telephone numbers went the other way.

#146 proposes one contract for every kind of contact detail — how it is shaped, whether it can be proved, and which connector proves it — with email, telephone and postal addresses as the three instances. Postal is the one that makes the contract honest: it can be validated and **cannot** be confirmed, short of posting something to it. That is a legitimate answer rather than a missing feature, and this issue is the reason the contract has to allow it. Nothing here is blocked by it — this issue can be built first and described by the contract afterwards — but the *not unique across the instance* decision in the title is a second axis the contract should carry too, since telephone numbers went the other way.
Author
Owner

Two things arrive on top of this issue, and neither changes what is written above.

It is an internal plugin, like telephone numbers (#90). That brings the plugin rules with it: #126 for what it may import, #127 for its own catalogues, #128 for what happens to a table of home addresses when the plugin goes, and #129 for the packaging. Note that #128 matters more here than for any plugin so far — this table holds the most sensitive thing Postulo would store, and uninstall and keep the table and uninstall and delete it are very different promises to make about somebody's home address.

Per-country validation is #147, which takes the paragraph here about formats differing by country and makes it the subject: required fields, postcode shapes, what each field is called, and where the data comes from. The not unique across the instance decision in this issue's title stands and is not revisited there.

Two things arrive on top of this issue, and neither changes what is written above. **It is an internal plugin**, like telephone numbers (#90). That brings the plugin rules with it: #126 for what it may import, #127 for its own catalogues, #128 for what happens to a table of home addresses when the plugin goes, and #129 for the packaging. Note that #128 matters more here than for any plugin so far — this table holds the most sensitive thing Postulo would store, and *uninstall and keep the table* and *uninstall and delete it* are very different promises to make about somebody's home address. **Per-country validation is #147**, which takes the paragraph here about formats differing by country and makes it the subject: required fields, postcode shapes, what each field is called, and where the data comes from. The *not unique across the instance* decision in this issue's title stands and is not revisited there.
Author
Owner

PostalAddress, owned, several per account, exactly one primary — with the invariant in a
database constraint rather than in whichever form saved last. On a person and on a contact,
through the same generic relation the telephone numbers use.

The difference from #90 is the whole point, and it is enforced rather than intended.
Unique per owner; freely shared between accounts. Two accounts holding one address is a
test rather than an accident:

A uniqueness constraint here would refuse the second member of a household their own
address, and the refusal would also disclose that somebody else on the server lives there

Both halves are covered — the second person at one address succeeds, and one person listing
their own home twice is refused.

Valid does not mean verified, for the reason phones.py already gives about dialling:
Postulo is not going to post anything, so it has no way to learn whether an address exists
and no use for the answer. An address typed oddly is saved exactly as typed; only the
comparison folds case and interior spacing, and Rua do Exemplo 1A is a different address
from Rua do Exemplo 1. Nothing here carries a verification, so nothing here can become a
way back into an account — asserted on the field list rather than on behaviour.

Postulo has stopped throwing real addresses away

The part the issue said makes this worth doing rather than tidy. The Europass importer kept
the town and the country and discarded the street and the postcode, because there was nowhere
to put them. They land now, filling blanks only — an import never argues with an address
somebody typed.

One thing surfaced doing it: tests/data/europass.xml had no street at all, while the JSON
fixture beside it did, and the two are meant to be one person. Invisible while both were
discarded; the XML now carries the same address.

Everything else the issue listed

  • core/export.py — format 9, postal_addresses on a profile and on a contact, and
    the importer reads them back. No skipping on import, because an archive cannot collide with
    somebody else's address — only with another row in the same file.
  • The ownership sweep — PostalAddress is an OwnedModel, so the sweep that walks every
    one of them covers it by construction.
  • docs/THREAT-MODEL.md — a new section saying a home address is the sharpest thing this
    application holds, with the three rules that follow: it is owner-scoped, it is deliberately
    not unique, and a CV header prints a town and a country and never a street.
  • The CV header — Profile.location is untouched and still its own overridable line.
    postal.location_line() is only what it can default from, and a test asserts the street
    never appears in it.

What is not here

Per-country rules — which fields are required, what a postcode looks like, what each field is
called, what order they print in. That is #147, which extends this, and the neutral labels
on the form today are what it replaces. The form structure will not change; the words will.

17 tests, 18 strings in all 39 European catalogues. Shipped in 5e05e74 on 0.3.0, with
main kept level.

`PostalAddress`, owned, several per account, exactly one primary — with the invariant in a database constraint rather than in whichever form saved last. On a person and on a contact, through the same generic relation the telephone numbers use. **The difference from #90 is the whole point, and it is enforced rather than intended.** Unique **per owner**; freely shared between accounts. Two accounts holding one address is a test rather than an accident: > A uniqueness constraint here would refuse the second member of a household their own > address, and the refusal would also disclose that somebody else on the server lives there Both halves are covered — the second person at one address succeeds, and one person listing their own home twice is refused. **Valid does not mean verified**, for the reason `phones.py` already gives about dialling: Postulo is not going to post anything, so it has no way to learn whether an address exists and no use for the answer. An address typed oddly is saved exactly as typed; only the comparison folds case and interior spacing, and `Rua do Exemplo 1A` is a different address from `Rua do Exemplo 1`. Nothing here carries a verification, so nothing here can become a way back into an account — asserted on the field list rather than on behaviour. ## Postulo has stopped throwing real addresses away The part the issue said makes this worth doing rather than tidy. The Europass importer kept the town and the country and discarded the street and the postcode, because there was nowhere to put them. They land now, filling blanks only — an import never argues with an address somebody typed. One thing surfaced doing it: `tests/data/europass.xml` had no street at all, while the JSON fixture beside it did, and the two are meant to be one person. Invisible while both were discarded; the XML now carries the same address. ## Everything else the issue listed - **`core/export.py`** — format **9**, `postal_addresses` on a profile and on a contact, and the importer reads them back. No skipping on import, because an archive cannot collide with somebody else's address — only with another row in the same file. - **The ownership sweep** — `PostalAddress` is an `OwnedModel`, so the sweep that walks every one of them covers it by construction. - **`docs/THREAT-MODEL.md`** — a new section saying a home address is the sharpest thing this application holds, with the three rules that follow: it is owner-scoped, it is deliberately not unique, and a CV header prints a town and a country and never a street. - **The CV header** — `Profile.location` is untouched and still its own overridable line. `postal.location_line()` is only what it can default *from*, and a test asserts the street never appears in it. ## What is not here Per-country rules — which fields are required, what a postcode looks like, what each field is *called*, what order they print in. That is #147, which extends this, and the neutral labels on the form today are what it replaces. The form structure will not change; the words will. 17 tests, 18 strings in all 39 European catalogues. Shipped in `5e05e74` 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#92
No description provided.