Mapping ISO 19115 Lineage to an OGC API - Records Record

Part of: ISO 19115 Lineage Implementation

An ISO 19139 metadata file locks lineage inside a verbose XML tree that catalog clients cannot filter or search efficiently; publishing the same lineage as an OGC API - Records record makes it a queryable GeoJSON resource that a modern catalog can index and return over HTTP. This how-to projects the LI_Lineage structure assembled in Implementing the ISO 19115-1 Lineage Model in a Spatial Pipeline into a Records-conformant GeoJSON record with a dedicated lineage property, so provenance travels with the discovery metadata rather than in a separate download.

Prerequisites

  • Python 3.10+ (standard library json is sufficient; no external dependency required).
  • The Lineage, Source, and ProcessStep model from the implementation overview, or any equivalent structured lineage object.
  • A catalog that serves the OGC API - Records “Record” core — each record is a GeoJSON Feature with id, type, geometry, properties, links, and conformsTo.
  • A stable dataset identifier and bounding geometry (GeoJSON, EPSG:4326 / CRS84 as the Records default).
ISO elements to GeoJSON record structure Identification maps to properties, extent maps to geometry and bbox, and lineage has no core property so needs a namespaced extension. ISO 19115 OGC API - Records feature MD_Identification title, abstract, keywords properties.title / description core properties — direct mapping EX_GeographicBoundingBox west, east, south, north geometry + bbox CRS84 only — reproject on the way out LI_Lineage / LI_ProcessStep the part you came for no core property exists use a namespaced extension property, and link to the full ISO record CRS84 is not optional — a bbox in any other CRS is silently misinterpreted, not rejected.

The CRS84 constraint is the mistake with the worst failure mode, because nothing complains. OGC API - Records inherits GeoJSON’s assumption that coordinates are longitude-latitude in WGS 84, so a bbox emitted in the dataset’s native projection is parsed successfully and places the record somewhere else entirely — often in the ocean, occasionally somewhere plausible enough that nobody notices. Reproject the extent at serialisation time and assert the result is within the valid longitude and latitude ranges before publishing.

The lineage row is the reason this mapping needs a design decision rather than a transformation table. The core record model has no lineage property, so the options are a namespaced extension property carrying a summary, a link relation pointing at the full ISO document, or both. Both is usually right: the extension makes lineage queryable within the catalogue, and the link gives a client that needs full detail somewhere authoritative to fetch it.

Implementation

The projection builds a GeoJSON Feature whose properties block carries the required Records fields (type, title, created) plus a nested lineage object derived directly from the ISO model. Each process step becomes an entry with its description, timestamp, and the source identifiers it consumed, and each source is echoed into the links array so clients can resolve inputs.

from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone

@dataclass
class Source:
    description: str
    citation_title: str
    identifier: str          # persistent URI — becomes a link href

@dataclass
class ProcessStep:
    description: str
    date_time: datetime
    processor_org: str
    source_ids: list[str] = field(default_factory=list)

@dataclass
class Lineage:
    statement: str | None = None
    sources: list[Source] = field(default_factory=list)
    steps: list[ProcessStep] = field(default_factory=list)

def to_records_record(lin: Lineage, *, record_id: str, title: str,
                      bbox_geometry: dict, created: datetime) -> dict:
    """Project an ISO 19115 lineage model into an OGC API - Records GeoJSON record."""
    source_links = [
        {
            "rel": "related",
            "href": src.identifier,
            "title": src.citation_title,
            "type": "application/json",
        }
        for src in lin.sources
    ]

    lineage_property = {
        "statement": lin.statement,
        "sources": [
            {"identifier": s.identifier, "title": s.citation_title,
             "description": s.description}
            for s in lin.sources
        ],
        "processSteps": [
            {
                "description": step.description,      # maps to LI_ProcessStep.description
                "stepDateTime": step.date_time.astimezone(timezone.utc).isoformat(),
                "processor": step.processor_org,
                "sources": step.source_ids,            # edges back to input identifiers
            }
            for step in lin.steps
        ],
    }

    return {
        "id": record_id,
        "conformsTo": [
            "http://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core"
        ],
        "type": "Feature",
        "geometry": bbox_geometry,
        "properties": {
            "type": "dataset",                         # resource type, not the GeoJSON type
            "title": title,
            "created": created.astimezone(timezone.utc).isoformat(),
            "lineage": lineage_property,               # ISO lineage lives here
        },
        "links": source_links,
    }

if __name__ == "__main__":
    lin = Lineage(
        statement="Orthorectified mosaic derived from three Sentinel-2 tiles.",
        sources=[Source("Sentinel-2 L1C tile T31UDQ", "Sentinel-2 MSI Level-1C",
                        "urn:asset:s2:T31UDQ:20260601")],
        steps=[ProcessStep(
            "Reprojected from EPSG:32631 to EPSG:3035 with cubic resampling.",
            datetime(2026, 6, 2, 9, 15, tzinfo=timezone.utc),
            "National Mapping Agency",
            ["urn:asset:s2:T31UDQ:20260601"],
        )],
    )
    record = to_records_record(
        lin,
        record_id="rec-mosaic-3035-20260602",
        title="Sentinel-2 orthomosaic (ETRS89-LAEA)",
        bbox_geometry={"type": "Polygon", "coordinates": [[
            [4.0, 51.0], [5.0, 51.0], [5.0, 52.0], [4.0, 52.0], [4.0, 51.0]]]},
        created=datetime(2026, 6, 2, 10, 0, tzinfo=timezone.utc),
    )
    print(json.dumps(record, indent=2))

The properties.type field is the resource type (dataset), distinct from the GeoJSON type of Feature at the top level — conflating the two is the most frequent mapping error. Lineage lives under properties.lineage; because Records treats properties as an open object, this custom key is valid while remaining invisible to clients that only read the core fields.

Identifier Strategy

Three identifier choices and what each survives File path, database row id and registered URI compared against reorganisation, catalogue migration and republication. IDENTIFIER REORGANISE MIGRATE REPUBLISH File path breaks breaks survives Catalogue row id survives breaks survives Registered URI survives survives survives Catalogue migration is the case people forget — and the one that happens every few years.

The middle row is where most catalogues sit and why identifier churn keeps recurring. A row identifier assigned by the catalogue software is stable for as long as that software is, and catalogues get replaced — at which point every external reference to a record breaks at once, including citations in publications and links from partner agencies. A URI minted from a namespace you control, stored as a dataset attribute and carried into whatever catalogue comes next, is the only option that survives all three columns.

Mint it once, at first publication, and record the minting as a lineage event. That gives an unambiguous answer to when a dataset acquired its public identity, which matters more than it sounds when the same underlying data has been circulating informally for years before it was formally published.

Verification

Confirm the output is a valid GeoJSON Feature and that the lineage round-trips. A quick structural assertion catches the common omissions:

import json
rec = json.loads(open("record.json").read())
assert rec["type"] == "Feature"
assert rec["properties"]["type"] == "dataset"
assert rec["properties"]["lineage"]["processSteps"][0]["description"]
assert rec["links"][0]["href"].startswith("urn:asset:")
print("OK — Records record carries resolvable lineage")

If your catalog exposes CQL2 filtering, you can then query records by lineage content — for example, retrieving every dataset whose processing referenced a given source identifier. This is the discovery payoff that the XML-only encoding cannot deliver, and it complements the graph-based traversal described in graph databases for lineage graphs: the catalog answers “which published datasets exist”, the graph answers “how deep does this chain go”.

Keeping the Record and the Service Consistent

Verifying the links a record advertises Each link relation the record advertises is followed by a verification job that checks the response matches the record's claims about CRS, format and identifier. The record claims a CRS, a format, an id rel=items → download does it return that CRS? rel=describedby → ISO same identifier? rel=license resolves at all? Verification job follows every link, asserts the claims Schema validation checks the record's shape. Only link-following checks whether it is true.

Link rot in a catalogue is not a cosmetic problem — a record advertising a download endpoint that has moved is a record that fails interoperability assessment and wastes every consumer’s time. Because the links point at services that change on their own schedule, no amount of care at generation time prevents it; only a job that periodically dereferences them does.

Make the checks assertive rather than merely reachability tests. A download link that returns 200 with data in a different CRS than the record advertises is worse than one that returns 404, because a consumer will use it and get silently misaligned results. Fetch a small sample, read its actual CRS, and compare to the claim.

Configuration Reference

Parameter Value Why
geometry CRS CRS84 (lon/lat, WGS 84) The only CRS GeoJSON assumes; anything else is silently misread
record id a registered URI you mint Survives reorganisation, catalogue migration and republication
lineage carrier namespaced extension property and a describedby link Queryable in the catalogue, authoritative detail one hop away
time interval, not instant, for datasets with a validity period An instant implies a snapshot the data is not
link type the actual media type served Consumers content-negotiate on it and fail confusingly when it lies
pagination server-side, with a stable sort Unstable sort makes paging skip and repeat records under concurrent writes

The pagination row catches people at exactly the wrong moment — during a partner’s first full harvest of a large catalogue. Paging without a deterministic sort key means records inserted mid-crawl shift the offsets, so the harvester silently misses some records and receives others twice. Sort by the record identifier, which is stable by construction, rather than by modification date, which changes precisely when records are being written.

The time row matters for the same reason CRS does: it is interpreted rather than validated. A dataset representing conditions over a year, published with a single timestamp, will be filtered out of any temporal search that does not happen to include that instant — which is most of them.

Gotchas & edge cases

  • Property naming is not standardized for lineage. The Records core defines type, title, created, updated, and keywords, but not a lineage field. Namespacing your key as lineage under properties is safe, yet consumers must know to read it; document the extension and keep the key stable so downstream CQL2 filters do not break. Avoid overloading the reserved description property with process-step text.
  • The links array is the only reliable place for resolvable inputs. Embedding source identifiers solely inside the nested lineage object hides them from generic catalog clients, which walk links to discover related resources. Echo each LI_Source identifier into links with rel: "related" (or rel: "derivedFrom" if your profile defines it) so both machine crawlers and the lineage property stay consistent.
  • Geometry CRS defaults to CRS84, not EPSG:4326 axis order. OGC API - Records expects coordinates in longitude, latitude order (CRS84). If your bounding box came from an EPSG:4326 source that used latitude, longitude, swap the axes before writing the record or the footprint will be transposed. Reproject any projected extent (such as the ETRS89-LAEA output above) back to CRS84 for the record geometry while keeping the projected CRS documented in the lineage statement.