Filling ISO 19115 Contact and Responsibility Blocks
Model a responsibility as a role plus an organisation plus a contact channel, resolve the organisation and channel from a live directory at generation time, and never write an individual’s name into a record that will outlive their employment.
The contact block is the part of an ISO 19115 record that is mandatory, structurally fiddly, and wrong in most catalogues within three years of being written. It is wrong for an entirely mundane reason: records record people, people leave, and nothing in the pipeline notices. The structural fiddliness — a CI_Responsibility wrapping a CI_Organisation wrapping a CI_Contact wrapping an address — is merely tedious. This guide sits under ISO 19115 metadata template generation, within Automated Metadata Generation & Schema Mapping.
Automated Python Implementation
The builder below takes a role and a directory key, resolves the current details, and emits the element tree. Resolution failure is an error rather than a fallback, because a contact block naming a team that no longer exists is worse than a build that stops.
#!/usr/bin/env python3
"""Build ISO 19115-3 responsibility blocks from a live team directory."""
from lxml import etree
NS = {
"cit": "http://standards.iso.org/iso/19115/-3/cit/2.0",
"gco": "http://standards.iso.org/iso/19115/-3/gco/1.0",
}
CODELIST = ("http://standards.iso.org/iso/19115/resources/Codelists/cat/"
"codelists.xml#CI_RoleCode")
VALID_ROLES = {
"resourceProvider", "custodian", "owner", "user", "distributor",
"originator", "pointOfContact", "principalInvestigator", "processor",
"publisher", "author", "sponsor", "coAuthor", "collaborator",
}
def qname(prefix, tag):
return etree.QName(NS[prefix], tag)
def character_string(parent, tag, value):
element = etree.SubElement(parent, qname("cit", tag))
text = etree.SubElement(element, qname("gco", "CharacterString"))
text.text = value
return element
def build_responsibility(role, organisation, email, url=None):
"""One CI_Responsibility element, fully nested, with a validated role."""
if role not in VALID_ROLES:
raise ValueError(f"{role!r} is not a CI_RoleCode value")
responsibility = etree.Element(qname("cit", "CI_Responsibility"), nsmap=NS)
role_element = etree.SubElement(responsibility, qname("cit", "role"))
code = etree.SubElement(role_element, qname("cit", "CI_RoleCode"))
code.set("codeList", CODELIST)
code.set("codeListValue", role)
code.text = role
party = etree.SubElement(responsibility, qname("cit", "party"))
org = etree.SubElement(party, qname("cit", "CI_Organisation"))
character_string(org, "name", organisation)
contact_info = etree.SubElement(org, qname("cit", "contactInfo"))
contact = etree.SubElement(contact_info, qname("cit", "CI_Contact"))
address_element = etree.SubElement(contact, qname("cit", "address"))
address = etree.SubElement(address_element, qname("cit", "CI_Address"))
character_string(address, "electronicMailAddress", email)
if url:
online_element = etree.SubElement(contact, qname("cit", "onlineResource"))
online = etree.SubElement(online_element, qname("cit", "CI_OnlineResource"))
character_string(online, "linkage", url)
return responsibility
def resolve(directory, key):
"""Look a team up in the live directory; absence is an error, not a default."""
entry = directory.get(key)
if entry is None:
raise KeyError(f"no directory entry for {key!r}; refusing to invent a contact")
if entry.get("retired"):
raise KeyError(f"directory entry {key!r} is retired; update the dataset's owner")
return entry
def responsibilities_for(dataset, directory):
"""The set of roles a published dataset must declare."""
blocks = []
for role, key in (("pointOfContact", dataset["contact_key"]),
("custodian", dataset["custodian_key"]),
("distributor", dataset.get("distributor_key"))):
if key is None:
continue
entry = resolve(directory, key)
blocks.append(build_responsibility(
role, entry["organisation"], entry["email"], entry.get("url")))
return blocks
The retired check is the mechanism that keeps records current. A directory entry marked retired raises at generation time, which surfaces the stale ownership on the next catalogue run rather than in a bounced email two years later. Without it, the pipeline will faithfully reproduce a dead address forever.
Validation and Pipeline Integration
Two assertions on structure and one on policy.
def test_role_code_carries_both_attribute_and_text():
element = build_responsibility("custodian", "GIS Team", "gis@example.gov")
code = element.find(f"{{{NS['cit']}}}role/{{{NS['cit']}}}CI_RoleCode")
assert code.get("codeListValue") == "custodian"
assert code.text == "custodian"
def test_invalid_role_is_refused():
try:
build_responsibility("maintainer", "GIS Team", "gis@example.gov")
except ValueError:
return
raise AssertionError("a non-CI_RoleCode value must be refused")
def test_retired_directory_entry_stops_generation():
directory = {"gis": {"organisation": "GIS Team", "email": "x@y.gov", "retired": True}}
try:
resolve(directory, "gis")
except KeyError:
return
raise AssertionError("a retired team must not be written into a record")
Wire the directory resolution into the generation step rather than caching contacts into the dataset record. A cached contact is a snapshot; the point of resolving at generation time is that regenerating the catalogue is what refreshes it.
Why Not Name Individuals
The instinct to name a person is strong, because a name feels more helpful than a functional address, and in the short term it is. Over the life of a metadata record it is reliably worse, for three reasons that compound.
The record outlives the role. A dataset published in 2019 and still served in 2026 will have passed through several owners. The name in the record is a fact about 2019, presented as a fact about now, and there is no mechanism by which a reader can tell.
Individual addresses are not monitored after departure. A functional address is redirected when a team reorganises; a personal one bounces or, worse, is silently discarded by a mailbox nobody reads. The failure is invisible from the sender’s side.
Naming a person in a published record is a data protection question. A catalogue record is published, indexed and harvested. Putting an individual’s name and work address into it is processing personal data with a lawful basis nobody has documented, for a benefit a functional address delivers equally well. Where an individual genuinely must be named — a principal investigator credited on a survey — that is a citation, and it belongs in the citation block rather than in the contact.
Where a team insists on a named contact, the compromise that works is to name the role and the team in the record and to resolve the current individual through the online resource link. The record stays true, and the reader still reaches a person.
Long-Term Compliance Best Practices
- Record functional addresses only. Team mailboxes are redirected on reorganisation; personal ones are not.
- Declare all three roles when they differ. A record with one contact for point of contact, custodian and distributor asserts that they are the same team, which is frequently untrue.
- Fail on a retired directory entry. It is the only mechanism that surfaces orphaned datasets, and it costs one field.
- Keep the directory outside the catalogue. It changes on a different schedule and is usually maintained by a different team.
- Regenerate records when the directory changes, not only when data changes. A reorganisation invalidates every record that references the affected teams.
Related
- ISO 19115 Metadata Template Generation — the surrounding record this block sits in
- Generating ISO 19115 Records from PostGIS Table Comments — where the directory key usually comes from
- Metadata Schema Validation & Linting — asserting that a contact resolves as a house lint rule
- Assessing Re-identification Risk in Point Datasets — the wider question of personal data in published spatial records