postulo-identifiers: one registry with a subject matrix, because ISNI is both #109

Closed
opened 2026-09-07 17:01:50 +00:00 by tiagoagueda · 1 comment
Owner

Observation

another internal plugin : postulo-identifiers
must include all identifiers from both companies as users with a ownership matrix so no
valid company identifier is shown on the user data

The separation asked for already exists — and that is the least interesting part

Two registries, both built on the same core.identifiers.Scheme:

company: crunchbase, lei, linkedin, opencorporates, other, register, wikidata
person : isni, orcid, other, researcherid, scopus
shared : other

CompanyIdentifier.scheme takes jobs.identifiers.CHOICES and a person's takes
accounts.identifiers.CHOICES, so a company scheme cannot appear on a person today. The
module says why:

The things being identified have nothing in common, but the machinery does... so neither
app has to depend on the other to get it.

So this issue is not for the separation. It is for the sentence above being wrong in an
interesting way
, and for what the separation costs.

The sets are not disjoint, and pretending they are loses real identifiers

Three of these identify people and organisations, and the split has quietly picked a side
for each:

Scheme Identifies Postulo offers it to
ISNI Public identities of contributors and organisations — that is its definition people only
Wikidata Anything with an item, which includes a great many people companies only
LinkedIn Company pages and personal profiles alike companies only

So a researcher cannot record their Wikidata item, and a university cannot record its ISNI
— which most of them have, and which is exactly the identifier an EU application form asks
an institution for. Neither is a bug anybody filed; both follow from the sets being kept in
two files that never had to agree.

The matrix fixes that by construction: a scheme says which subjects it can identify, and
three of them say both.

What the plugin would be, and why this one fits where contact details did not

Schemes are data and behaviour, not tables. CompanyIdentifier and PersonIdentifier
stay exactly where they are, in core, owned and migrated by core. What a plugin contributes
is the registry: a key, a label, a pattern, a checksum, a link template, an example, and
the subjects it applies to.

That is the difference from #100's second half. Contact details would have needed a plugin
to own a table, and plugins cannot. A scheme owns no rows.

And there is a real itch behind it. register is one generic scheme for every national
company register — SIRET, NIF, Companies House, KvK, Handelsregister — each with its own
format and its own checksum, none of which Postulo validates. A plugin per country could,
and that is precisely the case registry.py describes: "the person who cares about a
particular job board... should not have to wait for this project to accept a patch"
.

The matrix, and what it must enforce

Scheme(ORCID,    subjects={"person"})
Scheme(LEI,      subjects={"company"})
Scheme(ISNI,     subjects={"person", "company"})
  • A form offers only the schemes whose subjects include what it is editing.
  • A row whose scheme does not apply to its subject is refused at the model, not merely
    absent from a dropdown. That is what makes the requested guarantee a guarantee: today it
    rests on which module the choices were imported from, which is a convention, and a
    convention holds until somebody wires a form up differently.
  • other stays available to both, which it already effectively is — it is the one key in
    both registries today, and that overlap is the hint the matrix is the right model.

Care needed

No validation may get weaker. ORCID has a checksum and the module is pointed about why:
"the checksum catches the typos that a lookup would — which is the entire reason ORCID has
one."
Merging registries must carry every pattern and every check across, and the existing
tests should pass unchanged rather than be adjusted to fit.

Existing rows carry scheme keys. Any key that changes is a data migration, and there is
no reason for one to change — the keys are already distinct across the two registries except
other, which stays other.

Nothing may start touching the network. Both modules say so, twice, and a plugin
contributing a scheme must not be the loophole: a scheme validates what was typed and knows
where it links, and looking an identifier up somewhere else is "a deliberate act for
another day"
.

Scope

  • An identifier plugin kind, the way #99 added importer — internal only, no third-party
    group advertised until there is a contract for one.
  • postulo-identifiers as the internal plugin, holding both registries and the subject
    matrix, with the identity fields from #97.
  • subjects on Scheme, and a model-level check that refuses a mismatch.
  • ISNI offered to companies, Wikidata and LinkedIn offered to people.
  • Tests: every scheme's existing pattern and checksum still behaves; a person cannot be
    given an LEI through any route including the API; a company can be given an ISNI.

Classification

Enhancement. Depends on #97 for the identity fields and on #99's kind machinery as the
worked example.

## Observation > another internal plugin : postulo-identifiers > must include all identifiers from both companies as users with a ownership matrix so no > valid company identifier is shown on the user data ## The separation asked for already exists — and that is the least interesting part Two registries, both built on the same `core.identifiers.Scheme`: ``` company: crunchbase, lei, linkedin, opencorporates, other, register, wikidata person : isni, orcid, other, researcherid, scopus shared : other ``` `CompanyIdentifier.scheme` takes `jobs.identifiers.CHOICES` and a person's takes `accounts.identifiers.CHOICES`, so a company scheme cannot appear on a person today. The module says why: > The things being identified have nothing in common, but the machinery does... so neither > app has to depend on the other to get it. So this issue is not for the separation. It is for the sentence above being **wrong in an interesting way**, and for what the separation costs. ## The sets are not disjoint, and pretending they are loses real identifiers Three of these identify people *and* organisations, and the split has quietly picked a side for each: | Scheme | Identifies | Postulo offers it to | | --- | --- | --- | | **ISNI** | Public identities of **contributors and organisations** — that is its definition | people only | | **Wikidata** | Anything with an item, which includes a great many people | companies only | | **LinkedIn** | Company pages and personal profiles alike | companies only | So a researcher cannot record their Wikidata item, and a university cannot record its ISNI — which most of them have, and which is exactly the identifier an EU application form asks an institution for. Neither is a bug anybody filed; both follow from the sets being kept in two files that never had to agree. The matrix fixes that by construction: a scheme says which subjects it can identify, and three of them say both. ## What the plugin would be, and why this one fits where contact details did not **Schemes are data and behaviour, not tables.** `CompanyIdentifier` and `PersonIdentifier` stay exactly where they are, in core, owned and migrated by core. What a plugin contributes is the *registry*: a key, a label, a pattern, a checksum, a link template, an example, and the subjects it applies to. That is the difference from #100's second half. Contact details would have needed a plugin to own a table, and plugins cannot. A scheme owns no rows. And there is a real itch behind it. `register` is one generic scheme for **every** national company register — SIRET, NIF, Companies House, KvK, Handelsregister — each with its own format and its own checksum, none of which Postulo validates. A plugin per country could, and that is precisely the case `registry.py` describes: *"the person who cares about a particular job board... should not have to wait for this project to accept a patch"*. ## The matrix, and what it must enforce ```python Scheme(ORCID, subjects={"person"}) Scheme(LEI, subjects={"company"}) Scheme(ISNI, subjects={"person", "company"}) ``` - A form offers only the schemes whose subjects include what it is editing. - **A row whose scheme does not apply to its subject is refused at the model**, not merely absent from a dropdown. That is what makes the requested guarantee a guarantee: today it rests on which module the choices were imported from, which is a convention, and a convention holds until somebody wires a form up differently. - `other` stays available to both, which it already effectively is — it is the one key in both registries today, and that overlap is the hint the matrix is the right model. ## Care needed **No validation may get weaker.** ORCID has a checksum and the module is pointed about why: *"the checksum catches the typos that a lookup would — which is the entire reason ORCID has one."* Merging registries must carry every pattern and every check across, and the existing tests should pass unchanged rather than be adjusted to fit. **Existing rows carry scheme keys.** Any key that changes is a data migration, and there is no reason for one to change — the keys are already distinct across the two registries except `other`, which stays `other`. **Nothing may start touching the network.** Both modules say so, twice, and a plugin contributing a scheme must not be the loophole: a scheme validates what was typed and knows where it links, and looking an identifier up somewhere else is *"a deliberate act for another day"*. ## Scope - An `identifier` plugin kind, the way #99 added `importer` — internal only, no third-party group advertised until there is a contract for one. - `postulo-identifiers` as the internal plugin, holding both registries and the subject matrix, with the identity fields from #97. - `subjects` on `Scheme`, and a model-level check that refuses a mismatch. - ISNI offered to companies, Wikidata and LinkedIn offered to people. - Tests: every scheme's existing pattern and checksum still behaves; a person cannot be given an LEI through any route including the API; a company can be given an ISNI. ## Classification Enhancement. Depends on #97 for the identity fields and on #99's kind machinery as the worked example.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 17:01:50 +00:00
Author
Owner

Done in fea7b090.

The matrix

Scheme gains subjects, and three of them say both:

Scheme Identifies Was offered to Is offered to
ISNI contributors and organisations people both
Wikidata anything with an item companies both
LinkedIn profiles and company pages companies both
other anything both, separately both, once

So a university can record its ISNI and a researcher their Wikidata item, neither of which was possible before. Everything else keeps the side it had.

One scheme identifying two subjects turned out to need two addresses, which is a thing two registries could never have said: a LinkedIn company page is /company/<slug>/ and a personal profile is /in/<slug>/. Scheme.person_link exists for exactly that, and /in/ joins the paths a pasted URL is lifted from.

The guarantee, made a guarantee

Your ask was "no valid company identifier is shown on the user data", and you were right that the existing separation was a convention rather than a rule. It is now three things at once:

  • schemes_for(subject) — the picker offers only what applies, as before;
  • a field validator (Identifies("person")) that travels with the column, so a form and an import get the same answer;
  • save() on both models, because refused at the model has to mean objects.create too. That is the route a form never takes and an importer or a plugin might, and it is the one that decides whether this is a guarantee or a habit.

accounts.identifiers and jobs.identifiers keep their whole public surface but every function in each is scoped to its subject, so an LEI reaching the person's side is unknown scheme rather than badly formatted — which is the honest answer: there is no such scheme for a person.

What kind of plugin this is

The identifier kind is new and does two things differently, both deliberate.

It governs nothing. Every other kind answers is this on for this person; this one answers what does this key mean. Switching it off would leave every stored identifier without a label, a link or a check, which is not what off means anywhere else in Postulo — so it joins transport in UNGOVERNED_KINDS, for a different reason from the transport's.

It advertises no entry-point group, and that is how "internal only" is enforced rather than intended: GROUPS["identifier"] == "" and _load_third_party returns nothing for an empty group. When there is a contract worth promising, the group is one string.

You were right that this fits where contact details did not: a scheme owns no rows. PersonIdentifier and CompanyIdentifier stay in core, owned and migrated by core.

Nothing got weaker

Every pattern, both checksums, every URL-lifting rule and every normalisation came across. The per-scheme behaviour that used to be if scheme_key == ORCID: in two modules is now carried by the scheme itself (tidy, checksum, checksum_message, segments, lower), which is what made one table possible without one long branch.

tests/test_identifiers.py and tests/test_person_identifiers.py pass unchanged apart from one line that read identifiers.SCHEMES and now reads identifiers.schemes(). That was the check that mattered most — a merge is exactly where a validation quietly goes missing.

No key changed. choices came off both columns, for the same reason it came off CV.theme in #132: choices are frozen into every migration, so a scheme from a plugin could never be one. Every existing row keeps its value, and both migrations are AlterField with no data step.

The itch, acknowledged

register is still one generic scheme for SIRET, NIF, Companies House, KvK and Handelsregister alike, validated no further than a two-letter country prefix. That is now a plugin's job to improve rather than a patch to this repository — which is the case registry.py describes, and the reason the kind exists at all. The module says so where somebody looking at register would read it.

Also

  • postulo-identifiers ships its own locale/, 68 catalogues, with every existing translation carried across rather than re-typed. Three strings are new; all 39 European catalogues carry them.
  • tests/test_identifier_registry.py, 27 tests. Suite 4330 passed, 29 skipped; browser suite 54 passed.
  • docs/PLUGINS.md gains Identifier registries, and its list of shipped plugins is now eleven across eight kinds rather than the seven it still claimed.
Done in `fea7b090`. ## The matrix `Scheme` gains `subjects`, and three of them say both: | Scheme | Identifies | Was offered to | Is offered to | | --- | --- | --- | --- | | **ISNI** | contributors and organisations | people | **both** | | **Wikidata** | anything with an item | companies | **both** | | **LinkedIn** | profiles and company pages | companies | **both** | | `other` | anything | both, separately | both, once | So a university can record its ISNI and a researcher their Wikidata item, neither of which was possible before. Everything else keeps the side it had. **One scheme identifying two subjects turned out to need two addresses**, which is a thing two registries could never have said: a LinkedIn company page is `/company/<slug>/` and a personal profile is `/in/<slug>/`. `Scheme.person_link` exists for exactly that, and `/in/` joins the paths a pasted URL is lifted from. ## The guarantee, made a guarantee Your ask was *"no valid company identifier is shown on the user data"*, and you were right that the existing separation was a convention rather than a rule. It is now three things at once: - **`schemes_for(subject)`** — the picker offers only what applies, as before; - **a field validator** (`Identifies("person")`) that travels with the column, so a form and an import get the same answer; - **`save()` on both models**, because *refused at the model* has to mean `objects.create` too. That is the route a form never takes and an importer or a plugin might, and it is the one that decides whether this is a guarantee or a habit. `accounts.identifiers` and `jobs.identifiers` keep their whole public surface but every function in each is scoped to its subject, so an LEI reaching the person's side is *unknown scheme* rather than *badly formatted* — which is the honest answer: there is no such scheme for a person. ## What kind of plugin this is The `identifier` kind is new and does two things differently, both deliberate. **It governs nothing.** Every other kind answers *is this on for this person*; this one answers *what does this key mean*. Switching it off would leave every stored identifier without a label, a link or a check, which is not what *off* means anywhere else in Postulo — so it joins `transport` in `UNGOVERNED_KINDS`, for a different reason from the transport's. **It advertises no entry-point group**, and that is how "internal only" is enforced rather than intended: `GROUPS["identifier"] == ""` and `_load_third_party` returns nothing for an empty group. When there is a contract worth promising, the group is one string. You were right that this fits where contact details did not: **a scheme owns no rows**. `PersonIdentifier` and `CompanyIdentifier` stay in core, owned and migrated by core. ## Nothing got weaker Every pattern, both checksums, every URL-lifting rule and every normalisation came across. The per-scheme behaviour that used to be `if scheme_key == ORCID:` in two modules is now carried by the scheme itself (`tidy`, `checksum`, `checksum_message`, `segments`, `lower`), which is what made one table possible without one long branch. `tests/test_identifiers.py` and `tests/test_person_identifiers.py` pass **unchanged** apart from one line that read `identifiers.SCHEMES` and now reads `identifiers.schemes()`. That was the check that mattered most — a merge is exactly where a validation quietly goes missing. No key changed. `choices` came off both columns, for the same reason it came off `CV.theme` in #132: choices are frozen into every migration, so a scheme from a plugin could never be one. Every existing row keeps its value, and both migrations are `AlterField` with no data step. ## The itch, acknowledged `register` is still one generic scheme for SIRET, NIF, Companies House, KvK and Handelsregister alike, validated no further than a two-letter country prefix. That is now a plugin's job to improve rather than a patch to this repository — which is the case `registry.py` describes, and the reason the kind exists at all. The module says so where somebody looking at `register` would read it. ## Also - `postulo-identifiers` ships its own `locale/`, 68 catalogues, with every existing translation carried across rather than re-typed. Three strings are new; all 39 European catalogues carry them. - `tests/test_identifier_registry.py`, 27 tests. Suite 4330 passed, 29 skipped; browser suite 54 passed. - `docs/PLUGINS.md` gains **Identifier registries**, and its list of shipped plugins is now eleven across eight kinds rather than the seven it still claimed.
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#109
No description provided.