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
jsonis sufficient; no external dependency required). - The
Lineage,Source, andProcessStepmodel 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, andconformsTo. - A stable dataset identifier and bounding geometry (GeoJSON,
EPSG:4326/ CRS84 as the Records default).
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
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
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, andkeywords, but not a lineage field. Namespacing your key aslineageunderpropertiesis 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 reserveddescriptionproperty with process-step text. - The links array is the only reliable place for resolvable inputs. Embedding source identifiers solely inside the nested
lineageobject hides them from generic catalog clients, which walklinksto discover related resources. Echo eachLI_Sourceidentifier intolinkswithrel: "related"(orrel: "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:4326source 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.
Related
- ISO 19115 Lineage Implementation — the model these records project from
- INSPIRE Metadata Mandate — harvesting and discovery obligations
- Validating ISO 19115 Lineage with Python — gating before publication
- Structuring JSON/XML Lineage Documents — payload design behind the extension property
- Part of: ISO 19115 Lineage Implementation