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.

The nesting a single contact requiresA responsibility carries a role code and a party, which carries a name and a contact, which carries the actual channels.CI_Responsibilityone per rolerolecode list valuepointOfContactwho to askcustodianwho maintains itdistributorwho serves itpartyCI_Organisationnamethe team, not a personcontactInfoCI_Contactcontact channelsinside CI_ContactaddresselectronicMailAddressonlineResourcea resolvable URL
Four levels of nesting to record an email address. The role at the top is the part that carries meaning.

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.

Three roles, three different questionsThe point of contact, custodian and distributor roles compared by the question each answers and the consequence of conflating them.AnswersIf conflatedpointOfContactwho explains this datasetthe default; usually correctcustodianwho is responsible formaintaining itnobody knows who owns itdistributorwho serves the downloadaccess problems go to the wrongteamoriginatorwho produced it originallyprovenance lost
Most catalogues record one contact for all three, which answers the first question and silently guesses at the others.

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.

Resolving a contact at generation timeThe generator asks the directory for the current details of a team key, and the directory answers or refuses.GeneratorTeam directoryRecordresolve team keyorganisation, email, urlor: retired — refuseCI_Responsibility per role
The refusal path is the valuable one: it turns silent staleness into a build that stops.

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.