Handling Unmappable Fields in a Metadata Crosswalk

Route every field that has no counterpart into one of four declared outcomes — carried into a free-text element, defaulted with a provenance flag, deferred to a human queue, or explicitly dropped and logged — and never allow a fifth outcome in which a field simply disappears because no rule mentioned it.

Unmappable fields are not an edge case in metadata conversion; they are most of the interesting work. Every pair of standards was designed by different people for different purposes, and the overlap is smaller than the union in both directions. What separates a defensible conversion from a lossy one is not how many fields mapped, but whether the ones that did not are accounted for. This guide is part of metadata crosswalks and field mapping tables, under Automated Metadata Generation & Schema Mapping.

The four outcomes for an unmappable fieldA field with no counterpart is carried into free text, defaulted, deferred to a human, or dropped and logged.Does the target have a free-texthome?supplemental information, descriptionCarry it as prosenothing invented, nothing lostyesnoIs the target mandatory?Default and flagrecorded as pipeline-suppliedyesnoCould a person supply it?Defer to the queuerecord blocked, not publishedyesnoDrop and lognamed in the residue, never silent
Four outcomes, each declared. The fifth — silent disappearance — is what a residue list exists to prevent.

Automated Python Implementation

The residue tracker below wraps a crosswalk run and guarantees that every source path is accounted for by one of the four outcomes. Its central assertion is coverage: the union of mapped, carried, deferred and dropped paths must equal the set of paths present in the source.

#!/usr/bin/env python3
"""Account for every source field in a crosswalk run."""
import json
from dataclasses import dataclass, field


@dataclass
class Residue:
    """What happened to the parts of the source that did not map cleanly."""
    carried: dict = field(default_factory=dict)     # path -> value, into free text
    supplied: dict = field(default_factory=dict)    # target -> default written
    deferred: dict = field(default_factory=dict)    # path -> reason a human is needed
    dropped: dict = field(default_factory=dict)     # path -> reason for the drop

    def accounted(self):
        return set(self.carried) | set(self.deferred) | set(self.dropped)


# Declared policy for the source paths that no mapping rule covers.
UNMAPPED_POLICY = {
    "idinfo/native": {"action": "carry", "into": "supplementalInformation"},
    "idinfo/crossref": {"action": "carry", "into": "supplementalInformation"},
    "dataqual/attracc": {"action": "defer", "reason": "no structured ISO equivalent"},
    "distinfo/custom": {"action": "drop", "reason": "site-specific, superseded"},
}


def account_for_source(flat, mapped_sources, policy=UNMAPPED_POLICY):
    """Classify every source path that no rule consumed."""
    residue = Residue()
    for path, values in sorted(flat.items()):
        if path in mapped_sources:
            continue
        rule = policy.get(path)
        if rule is None:
            # The important case: an unknown field is deferred, never dropped.
            residue.deferred[path] = "no policy declared for this source path"
        elif rule["action"] == "carry":
            residue.carried[path] = {"into": rule["into"], "values": values}
        elif rule["action"] == "defer":
            residue.deferred[path] = rule["reason"]
        else:
            residue.dropped[path] = rule["reason"]
    return residue


def assert_full_coverage(flat, mapped_sources, residue):
    """Every source path must be mapped or accounted for. No exceptions."""
    unexplained = set(flat) - set(mapped_sources) - residue.accounted()
    if unexplained:
        raise AssertionError(
            "source paths neither mapped nor accounted for: "
            + ", ".join(sorted(unexplained))
        )


def render_carried(residue, separator="\n\n"):
    """Compose the free-text block from the carried fields, deterministically."""
    blocks = []
    for path in sorted(residue.carried):
        entry = residue.carried[path]
        label = path.rsplit("/", 1)[-1]
        blocks.append(f"{label}: " + "; ".join(entry["values"]))
    return separator.join(blocks)

The default for an unknown path is defer, not drop, and that choice is the whole design. A crosswalk encountering a source field nobody has classified has found something its authors did not know about, which is precisely the case where silent behaviour is most damaging. Deferring makes it visible; dropping makes it disappear with the appearance of success.

The four outcomes comparedCarry, default, defer and drop compared by what reaches the output, what is recorded and who has to act.Reaches the outputRecorded asSomeone must actCarryyes, as prosecarried, with the source pathnoDefaultyes, inventedsupplied, with the rule idnoDeferno — record helddeferred, with a reasonyesDropnodropped, with a reasonno
Only one outcome puts something in the output that no source supplied, and only one blocks publication.

Validation and Pipeline Integration

Coverage is the assertion; everything else is reporting.

def test_every_source_path_is_accounted_for():
    flat = {"idinfo/citation/citeinfo/title": ["A"], "idinfo/native": ["ArcInfo 7.2"]}
    mapped = {"idinfo/citation/citeinfo/title"}
    residue = account_for_source(flat, mapped)
    assert_full_coverage(flat, mapped, residue)


def test_unknown_path_defers_rather_than_drops():
    flat = {"idinfo/somethingnew": ["x"]}
    residue = account_for_source(flat, set())
    assert "idinfo/somethingnew" in residue.deferred
    assert not residue.dropped


def test_carried_block_is_deterministic():
    flat = {"idinfo/crossref": ["b"], "idinfo/native": ["a"]}
    residue = account_for_source(flat, set())
    assert render_carried(residue) == render_carried(residue)

Emit the residue as a machine-readable artifact from every run and diff it between runs in CI. A residue that grows without an accompanying policy change means an upstream source has started emitting fields nobody has classified — which is the earliest possible warning that a source schema has changed, and considerably cheaper to act on than the validation failures that follow.

Where the fields go in a real CSDGM to ISO conversionDistribution of source fields by outcome across a legacy archive conversion.Mapped by a rule64%of 1,180 distinct pathsCarried as free text14%Defaulted by the pipeline9%Deferred to a human8%Dropped by policy5%
Two thirds map cleanly. The remaining third is the work, and it is invisible unless it is counted.

Writing the Drop Policy Down

Dropping a field is a decision with consequences that outlive everyone involved, and it should read like one. A policy entry with a reason of "not needed" is not a decision; it is the absence of one, recorded.

A usable entry answers three questions. What was in the field — a short description, because the source standard’s documentation will not always be at hand in ten years. Why it has no target — whether the target standard genuinely lacks the concept, or has it somewhere the crosswalk has chosen not to populate, which are different situations. And what would need to change for the decision to be revisited: a new target element, a profile extension, or simply somebody deciding the information matters.

The category that most often deserves a second look is the one dismissed as site-specific. Native environment strings, custom distribution notes and local processing remarks look like clutter and are frequently the only surviving record of how a dataset was produced. Carrying them into a free-text element costs a few hundred bytes per record and preserves something that cannot be reconstructed. Dropping them is defensible; dropping them without recording that they existed is not.

One further habit is worth adopting: review the drop list when the target standard revises. Elements get added, and a field dropped in 2019 for want of a target may have one now. Nothing will surface that automatically, because the crosswalk works and produces no complaint. A ten-minute review at each standard update is what turns a permanent loss into a temporary one.

Long-Term Compliance Best Practices

  • Default unknown paths to defer. The one behaviour that must never be silent is the appearance of a field nobody has classified.
  • Keep the residue artifact with the converted record. It is the answer to “what did we lose”, and it is only credible if it was produced by the run rather than reconstructed afterwards.
  • Count deferrals as a work queue, not a failure rate. A conversion with two hundred deferrals is not going badly; it has a list.
  • Never let a default silently satisfy a mandatory target twice. Two records defaulting the same contact are fine; two hundred means the field should be sourced properly or the default should be reviewed.
  • Diff the residue between runs. A growing residue with an unchanged policy is a source schema change, detected before it becomes a validation failure.