Lineage Manifest Generation Jump to heading
Every coordinate transformation, field cast, and validation gate in a geospatial pipeline is a claim: this feature was processed this way, at this time, with this outcome. A lineage manifest is the durable record of those claims. It is the artefact an auditor reads instead of trusting the pipeline’s word, and it is what turns a one-off transform into regulator-ready evidence. This stage sits at the foundation of Geospatial Compliance Reporting & Audit Trails: where CRS normalization and attribute ETL do the work, the lineage manifest proves the work was done, in what order, and with which result.
This page owns the record contract and the store: what a lineage event must contain, how upstream stages emit one row per event, how events are deduplicated and ordered, and how the resulting dataset is retained and queried. It stops short of two implementation details that have their own runnable guides. The physical mechanics of appending immutably to a columnar dataset — partition layout, atomic writes, schema-evolution guardrails — belong to writing append-only lineage manifests to Parquet. Rendering the stored rows into standards-compliant metadata for a catalogue is covered by generating ISO 19115 lineage statements automatically. Everything between those two — the schema, the emission contract, dedup, retention — is scoped here.
Upstream, this stage is a consumer. It ingests the lineage rows already produced by the CRS Normalization & Sync pipeline and the rejection logs emitted by Error Handling & Retry Logic, and it standardizes both into a single auditable trail. It does not re-run transformations; it records them.
The Lineage Record Contract Jump to heading
A manifest is only as trustworthy as the schema it enforces. Declare the lineage record as a version-controlled contract, then compile that contract into an Arrow schema so a malformed row is rejected at write time rather than discovered during an audit years later. The manifest below is the field catalogue; the Arrow schema derived from it is the enforcement mechanism.
# lineage_schema.yaml — pyarrow >=14, pydantic >=2.0
schema_version: "1.2.0" # MANDATORY: bumped on any additive change
partition_keys: ["run_id", "target_crs"] # MANDATORY: directory partition columns
fields:
# --- MANDATORY EVENT IDENTITY ---
- name: event_id # deterministic hash, primary dedup key
type: string
required: true
- name: run_id # pipeline execution id; a partition key
type: string
required: true
- name: sequence # monotonic int within a run_id; total event ordering
type: int64
required: true
- name: event_time_utc # ISO 8601, UTC, when the event occurred
type: timestamp[us, tz=UTC]
required: true
# --- MANDATORY PROVENANCE ---
- name: stage # emitting stage: crs_normalize | attr_cast | validate | ...
type: string
required: true
- name: event_type # TRANSFORM | VALIDATE | REJECT | CAST | SNAP
type: string
required: true
- name: feature_id # stable id of the feature acted on
type: string
required: true
- name: source_crs # EPSG/WKT2 of the input geometry (nullable for attr-only events)
type: string
required: true
- name: target_crs # EPSG/WKT2 of the output geometry; a partition key
type: string
required: true
- name: outcome # PASS | REJECT | WARN
type: string
required: true
# --- OPTIONAL DIAGNOSTICS ---
- name: strategy_index # which fallback path ran (from the CRS stage)
type: int32
required: false
- name: accuracy_m # reported horizontal accuracy in metres
type: float64
required: false
- name: rejection_reason # full exception text for REJECT outcomes
type: string
required: false
- name: tool_version # "pyproj 3.6.1 / PROJ 9.3.1" — reproducibility anchor
type: string
required: false
- name: audit_tag # freeform tag propagated from upstream config
type: string
required: false
Mandatory vs optional field reference:
| Field | Required | Arrow type | Purpose |
|---|---|---|---|
event_id |
Yes | string |
Deterministic dedup key; identical across replays of the same event |
run_id |
Yes | string |
Pipeline execution identifier; first partition key |
sequence |
Yes | int64 |
Monotonic within a run; establishes total ordering for replay |
event_time_utc |
Yes | timestamp[us, tz=UTC] |
Wall-clock time the event occurred; never the write time |
stage |
Yes | string |
Which pipeline stage emitted the row |
event_type |
Yes | string |
Enumerated action class; enables typed audit queries |
feature_id |
Yes | string |
Stable feature identity across the whole pipeline |
source_crs / target_crs |
Yes | string |
Spatial provenance; target_crs is the second partition key |
outcome |
Yes | string |
PASS, REJECT, or WARN; drives conformance scoring |
strategy_index |
No | int32 |
Fallback tier that ran, mirrored from the CRS stage |
accuracy_m |
No | float64 |
Reported positional accuracy for the selected transform |
rejection_reason |
No | string |
Diagnostic text; populated only on REJECT |
tool_version |
No | string |
PROJ/pyproj build string; the reproducibility anchor an auditor needs |
audit_tag |
No | string |
Freeform tag carried from upstream manifests |
Two decisions here are load-bearing. First, event_time_utc records when the event happened, never when the row was written — batching and retries decouple the two, and an auditor reconstructing a timeline needs the former. Second, event_id is deterministic, not random: it is a hash of the tuple that uniquely identifies an event, so a replayed batch reproduces identical ids that deduplicate cleanly. Treat lineage_schema.yaml and its schema_version as part of the audit contract; a field rename is a breaking change that must go through schema evolution, not an in-place edit.
Preprocessing Requirements Jump to heading
Before any stage emits a row, three preconditions must hold, or the manifest silently loses its guarantees.
-
Every event has a stable
feature_id. Lineage that cannot be joined back to a feature is noise. If source features lack a durable identifier, mint one deterministically upstream (a hash of the source path plus the original FID) and thread it through every stage. Afeature_idthat changes between the CRS stage and the attribute stage breaks the provenance chain for that feature. -
Clocks are UTC and monotonic within a run.
event_time_utcmust be timezone-aware UTC. Thesequencefield is assigned by a per-run counter, not by clock time, so two events in the same microsecond still order deterministically. Never derive ordering fromevent_time_utcalone. -
The upstream rejection log is normalized first. The Error Handling & Retry Logic stage writes rejections in its own NDJSON shape. Map those into the lineage record contract (a
REJECToutcome withrejection_reasonpopulated) before they enter the manifest, so audit queries see one uniform schema rather than two dialects.
# pipeline/lineage/identity.py — python 3.10+, pyarrow >=14
from __future__ import annotations
import hashlib
def make_event_id(run_id: str, stage: str, feature_id: str, event_type: str) -> str:
"""Deterministic event identity: identical inputs always hash to the same id.
This is the primary deduplication key. A retried batch re-emits the same
logical events, which reproduce identical ids and collapse at audit time.
"""
key = f"{run_id}\x1f{stage}\x1f{feature_id}\x1f{event_type}"
return hashlib.blake2b(key.encode("utf-8"), digest_size=16).hexdigest()
Execution Engine: Emitting and Collecting Rows Jump to heading
The emission contract is deliberately narrow: each stage produces exactly one LineageEvent per feature-action, hands it to a collector, and never touches the store directly. Centralizing writes in a single collector is what makes the append-only and dedup guarantees enforceable — a stage cannot corrupt the manifest because it never holds a file handle to it.
# pipeline/lineage/collector.py — python 3.10+, pyarrow >=14, pydantic >=2.0
from __future__ import annotations
import datetime as dt
import logging
from dataclasses import dataclass, field
import pyarrow as pa
from pyarrow import ArrowInvalid, ArrowTypeError
from pipeline.lineage.identity import make_event_id
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class LineageEvent:
run_id: str
sequence: int
stage: str
event_type: str # TRANSFORM | VALIDATE | REJECT | CAST | SNAP
feature_id: str
source_crs: str
target_crs: str
outcome: str # PASS | REJECT | WARN
event_time_utc: dt.datetime
strategy_index: int | None = None
accuracy_m: float | None = None
rejection_reason: str | None = None
tool_version: str | None = None
audit_tag: str | None = None
def event_id(self) -> str:
return make_event_id(self.run_id, self.stage, self.feature_id, self.event_type)
# Compiled once from lineage_schema.yaml — the single enforcement point.
LINEAGE_ARROW_SCHEMA = pa.schema([
("event_id", pa.string()),
("run_id", pa.string()),
("sequence", pa.int64()),
("event_time_utc", pa.timestamp("us", tz="UTC")),
("stage", pa.string()),
("event_type", pa.string()),
("feature_id", pa.string()),
("source_crs", pa.string()),
("target_crs", pa.string()),
("outcome", pa.string()),
("strategy_index", pa.int32()),
("accuracy_m", pa.float64()),
("rejection_reason", pa.string()),
("tool_version", pa.string()),
("audit_tag", pa.string()),
])
_VALID_OUTCOMES = {"PASS", "REJECT", "WARN"}
@dataclass
class LineageCollector:
"""Accumulates events in memory and materializes a validated Arrow table.
The collector is the ONLY component permitted to build manifest rows.
Stages call add(); the writer (see the Parquet guide) consumes to_table().
"""
_events: list[LineageEvent] = field(default_factory=list)
def add(self, event: LineageEvent) -> None:
if event.outcome not in _VALID_OUTCOMES:
# A bad outcome value must never reach the store — fail loudly.
raise ValueError(
f"Invalid outcome {event.outcome!r} for feature {event.feature_id!r}; "
f"expected one of {sorted(_VALID_OUTCOMES)}."
)
if event.event_time_utc.tzinfo is None:
raise ValueError(
f"event_time_utc for {event.feature_id!r} is naive; UTC tzinfo is required."
)
self._events.append(event)
def to_table(self) -> pa.Table:
"""Materialize a schema-validated Arrow table, or raise before any write."""
if not self._events:
return LINEAGE_ARROW_SCHEMA.empty_table()
columns = {name: [] for name in LINEAGE_ARROW_SCHEMA.names}
for ev in self._events:
columns["event_id"].append(ev.event_id())
columns["run_id"].append(ev.run_id)
columns["sequence"].append(ev.sequence)
columns["event_time_utc"].append(ev.event_time_utc)
columns["stage"].append(ev.stage)
columns["event_type"].append(ev.event_type)
columns["feature_id"].append(ev.feature_id)
columns["source_crs"].append(ev.source_crs)
columns["target_crs"].append(ev.target_crs)
columns["outcome"].append(ev.outcome)
columns["strategy_index"].append(ev.strategy_index)
columns["accuracy_m"].append(ev.accuracy_m)
columns["rejection_reason"].append(ev.rejection_reason)
columns["tool_version"].append(ev.tool_version)
columns["audit_tag"].append(ev.audit_tag)
try:
table = pa.table(columns, schema=LINEAGE_ARROW_SCHEMA)
except (ArrowInvalid, ArrowTypeError) as exc:
# A type mismatch means an upstream stage violated the contract.
logger.critical("Lineage table failed schema validation: %s", exc)
raise
return table
The to_table() call is the choke point where the contract is enforced. If any stage emitted a value that does not fit LINEAGE_ARROW_SCHEMA — a float where an int belongs, a string where a timestamp is required — pa.table(...) raises ArrowInvalid or ArrowTypeError before a single byte reaches disk. That failure is desirable: a manifest that silently coerces bad rows is worse than one that halts.
Deduplication and Ordering Jump to heading
Retries, replays, and at-least-once queue delivery all reintroduce the same logical event. Deduplication is therefore not optional cleanup — it is a correctness requirement, because a lineage manifest that double-counts a REJECT will misreport a dataset’s conformance. Two mechanisms cooperate: deterministic event_id for identity and monotonic sequence for order.
# pipeline/lineage/dedup.py — python 3.10+, pyarrow >=14
from __future__ import annotations
import pyarrow as pa
import pyarrow.compute as pc
def deduplicate(table: pa.Table) -> pa.Table:
"""Collapse re-emitted events, keeping the highest sequence per event_id.
Because event_id is deterministic, a replayed batch produces duplicate ids.
Keeping the max sequence preserves the most recent authoritative record.
"""
if table.num_rows == 0:
return table
# Stable sort by (event_id, sequence) so the last row per id is authoritative.
order = pc.sort_indices(
table, sort_keys=[("event_id", "ascending"), ("sequence", "ascending")]
)
ordered = table.take(order)
ids = ordered.column("event_id").to_pylist()
keep = [i for i in range(len(ids)) if i + 1 == len(ids) or ids[i] != ids[i + 1]]
return ordered.take(pa.array(keep, type=pa.int64()))
Deduplication runs at audit-read time, not at write time. This is deliberate and it is what keeps the store genuinely append-only: the writer never deletes a superseded row, it simply appends the corrected one with a higher sequence, and the reader resolves which row is authoritative. The full physical history survives — an auditor can see that an event was retried — while queries see one row per event. The write-side mechanics that preserve that history are detailed in writing append-only lineage manifests to Parquet.
Failure Modes Jump to heading
A lineage manifest fails quietly if you let it. Each failure below must map to an explicit, deterministic action; none may degrade to a dropped row.
| Failure mode | Likely cause | Deterministic action |
|---|---|---|
Row rejected by to_table() |
Upstream stage emitted a wrong-typed value | ArrowInvalid/ArrowTypeError halts the flush; fix the emitter, never coerce |
| Missing mandatory field | Stage instrumented incompletely | Reject at LineageEvent construction; the dataclass makes the field non-optional |
Duplicate event_id, differing payload |
Non-deterministic id inputs (e.g. wall-clock in the key) | Audit alert: ids must be reproducible; recompute make_event_id inputs |
Naive (tz-less) event_time_utc |
Local-time datetime leaked from a stage | ValueError at add(); require datetime.now(tz=UTC) at the source |
Non-monotonic sequence within a run |
Parallel workers sharing a counter without coordination | Assign sequence from a single ordered source per run; see partition guide |
Gap in feature_id chain |
Feature re-keyed mid-pipeline | Completeness check fails in CI: every PASS at stage N needs a prior row at N-1 |
| Partition explosion | target_crs free-text instead of canonical EPSG |
Normalize target_crs to EPSG:xxxx before it becomes a partition key |
The partition-explosion row is the one that bites in production. If target_crs arrives as raw WKT2 strings that differ by whitespace, each variant becomes its own partition directory and the dataset fragments into thousands of tiny files. Canonicalize target_crs to an EPSG:xxxx code — the same normalization the CRS Normalization & Sync stage already performs — before it is used as a partition key.
Compliance Reporting Output Jump to heading
The manifest exists to be queried. Because it is a partitioned Parquet dataset, any Arrow-aware engine reads it without a database server, and partition pruning on run_id and target_crs keeps audit scans cheap even at multi-year scale.
# audit/report.py — python 3.10+, pyarrow >=14
from __future__ import annotations
import pyarrow.dataset as ds
import pyarrow.compute as pc
from pipeline.lineage.dedup import deduplicate
def conformance_summary(manifest_root: str, run_id: str) -> dict[str, int]:
"""Per-outcome counts for one pipeline run, read straight from partitions."""
dataset = ds.dataset(manifest_root, format="parquet", partitioning="hive")
# Partition pruning: only run_id=<run_id> directories are touched.
table = dataset.to_table(filter=ds.field("run_id") == run_id)
table = deduplicate(table)
outcomes = table.column("outcome")
return {
"total_events": table.num_rows,
"passed": pc.sum(pc.equal(outcomes, "PASS")).as_py() or 0,
"rejected": pc.sum(pc.equal(outcomes, "REJECT")).as_py() or 0,
"warned": pc.sum(pc.equal(outcomes, "WARN")).as_py() or 0,
}
That summary is the raw material for two downstream artefacts. Rendered as XML, the same rows become ISO 19115 LI_Lineage and LI_ProcessStep elements for a metadata catalogue — the subject of generating ISO 19115 lineage statements automatically. Aggregated across runs, the pass/reject ratios feed the conformance scorecards that gate merges. The lineage manifest is the single source both draw from, which is what keeps a published metadata record and a CI scorecard from ever disagreeing.
Retention. Partition by run date so expiry is a directory operation, never a row-level delete against an append-only store. Set the window to the strictest obligation the data is subject to: INSPIRE-aligned datasets retain lineage for a minimum of six years; FGDC and ISO 19115 programs commonly require the life of the dataset plus a fixed archival tail. Because partitions are immutable directories, a retention job is a safe rm -rf of run_date= prefixes older than the window, with no risk of corrupting live history.
CI Integration Jump to heading
Two properties must be gated in continuous integration: the manifest is complete (every downstream event has its upstream predecessor) and the store is hygienic (canonical partitions, no orphaned schema versions). Run both on every pull request that touches pipeline instrumentation.
# tests/test_lineage_manifest.py — pytest >=7, pyarrow >=14
import pyarrow as pa
from pipeline.lineage.collector import LineageCollector, LineageEvent, LINEAGE_ARROW_SCHEMA
from pipeline.lineage.dedup import deduplicate
def test_schema_is_frozen():
"""The Arrow schema must match the versioned contract exactly."""
assert LINEAGE_ARROW_SCHEMA.field("event_time_utc").type == pa.timestamp("us", tz="UTC")
assert LINEAGE_ARROW_SCHEMA.field("sequence").type == pa.int64()
assert "rejection_reason" in LINEAGE_ARROW_SCHEMA.names
def test_replay_deduplicates(sample_event):
"""Emitting the same logical event twice must collapse to one row."""
c = LineageCollector()
c.add(sample_event)
c.add(sample_event) # simulated retry: identical event_id
table = deduplicate(c.to_table())
assert table.num_rows == 1
def test_reject_carries_reason():
"""A REJECT outcome without a rejection_reason is a broken audit trail."""
c = LineageCollector()
# ... build a REJECT event without rejection_reason ...
# completeness rule enforced in the full suite:
table = c.to_table()
for outcome, reason in zip(
table.column("outcome").to_pylist(),
table.column("rejection_reason").to_pylist(),
):
if outcome == "REJECT":
assert reason, "REJECT rows must populate rejection_reason"
# .github/workflows/validate-lineage.yml
name: Validate Lineage Manifest
on: [push, pull_request]
jobs:
lineage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install "pyarrow>=14" "pydantic>=2.0" "pandas>=2.0" "pytest>=7"
- name: Validate lineage schema contract
run: python -m pipeline.lineage.validate_schema lineage_schema.yaml
- name: Run manifest tests
run: pytest tests/test_lineage_manifest.py -v --tb=short
- name: Assert partition hygiene
run: python -m audit.check_partitions --root ./_manifest --require-epsg-target
The check_partitions step is the one most teams omit and most regret: it walks the dataset directory and fails the build if any target_crs= partition is not a canonical EPSG:xxxx value, catching the fragmentation failure before it reaches production storage. Wrap transient object-store write failures in the retry patterns from Error Handling & Retry Logic — a flaky S3 PUT should back off and retry, not drop a lineage row.
Frequently Asked Questions Jump to heading
Why a partitioned Parquet dataset instead of a relational audit table?
An append-only Parquet dataset is immutable by construction — each flush writes a new file and never rewrites an old one — which matches the legal shape of an audit trail far better than a mutable SQL table that a stray UPDATE can silently rewrite. It is also columnar, so conformance scans over billions of rows read only the two or three columns they need, and it is portable to any object store without keeping a database running for the full multi-year retention window.
How is the manifest truly append-only if deduplication removes rows?
Deduplication never touches the store. The writer only ever appends; a corrected event is a new row with a higher sequence, and the superseded row stays on disk forever. Deduplication happens in the reader, at query time, resolving which row is authoritative. The physical file history is complete and immutable; only the logical view collapses duplicates.
What stops a retried batch from double-counting events?
event_id is a deterministic hash of (run_id, stage, feature_id, event_type), so re-processing the same batch reproduces byte-identical ids. At audit time those identical ids collapse to one row per event. Randomly generated ids would defeat this entirely, which is why the id must be derived, never assigned.
How does this stage relate to the CRS pipeline’s own lineage records? The CRS Normalization & Sync stage emits transform lineage in its own local shape for operational debugging. This stage consumes those rows, maps them onto the canonical record contract, and standardizes them alongside attribute-ETL and rejection events into one auditable manifest. It is an aggregation and standardization layer, not a second source of truth.
Related Jump to heading
- Writing Append-Only Lineage Manifests to Parquet — the physical write mechanics, partition layout, and immutability verification for the store described here
- Generating ISO 19115 Lineage Statements Automatically — rendering the stored rows into standards-compliant catalogue metadata
- Geospatial Compliance Reporting & Audit Trails — the practice area that turns pipeline events into regulator-ready evidence
- CRS Normalization & Sync — the upstream pipeline whose transform lineage this manifest consumes
- Error Handling & Retry Logic — the rejection-log source normalized into the manifest, and the backoff wrappers for transient store writes