Choosing a License for Derived Spatial Products

The license of a derived spatial product is not chosen — it is computed: the output must carry the most restrictive share-alike obligation among its materially contributing sources, and if two sources impose incompatible copyleft terms, no lawful open license exists for the combination. Once you accept that the output license is a function of the inputs and the operation, licensing a derived layer stops being a judgement call and becomes a deterministic resolution you can automate. This guide shows the resolution logic and gives you a runnable resolver.

Derivation is the normal state of geospatial work. Analysts clip a national landcover raster to a study area, join demographic attributes onto census polygons, and enrich a road network with speed estimates from a third source. Each operation produces a new product whose obligations flow from every input that contributed to it. This page is a focused companion to Open Data License Selection & Comparison and sits within Geospatial Data Licensing & Compliance Fundamentals; where the parent guide covers selecting a license for an original dataset, this one covers the harder case where the license is inherited from several sources at once.

How derivation determines the output license

Three facts about a derivation decide the output license:

  1. Which sources materially contribute. A layer used only as a spatial mask for clipping may contribute nothing to the output’s content — the clipped result contains only the source being clipped. But a layer whose attributes or geometry appear in the output is a material contributor whose license propagates. Distinguishing the two is the first and most error-prone judgement.
  2. Whether the output is publicly distributed. ODbL’s share-alike fires only on public distribution of a derivative database; an internal-only product escapes it. CC BY-SA fires on any shared adaptation. If nothing is distributed, no share-alike obligation propagates at all.
  3. Which copyleft terms are present. A single share-alike source imposes its license on the whole output. Two compatible attribution sources simply require credit. Two incompatible copyleft sources — classically ODbL and CC BY-SA — produce a deadlock with no lawful output.

The governing rule is most-restrictive-wins: order the licenses by how demanding their obligations are, and the output must satisfy the strictest one present. Where two equally strict copyleft licenses conflict, the resolution is not a stricter license but no license — the combination cannot be distributed until a source is changed.

Automated Python Implementation

The resolver below takes a list of contributing sources — each with an SPDX license and a flag for whether it materially contributes content — plus the deployment context, and returns the license the output must carry (or a structured conflict when none exists). It is self-contained and uses only the standard library.

#!/usr/bin/env python3
"""Resolve the license of a derived spatial product from its sources.

Encodes most-restrictive-wins logic across the common open spatial
licenses, honouring ODbL's public-distribution-only share-alike trigger.
"""
from dataclasses import dataclass
from typing import List, Optional

# Restrictiveness rank: higher = more demanding obligations.
RANK = {
    "CC0-1.0": 0,
    "PDDL-1.0": 0,
    "CC-BY-4.0": 1,
    "OGL-UK-3.0": 1,
    "ODbL-1.0": 2,
    "CC-BY-SA-4.0": 2,
}
SHARE_ALIKE = {"ODbL-1.0", "CC-BY-SA-4.0"}
# Pairs of share-alike licenses that cannot co-exist in one distributed work.
INCOMPATIBLE_SA_PAIRS = {frozenset({"ODbL-1.0", "CC-BY-SA-4.0"})}


@dataclass
class Source:
    name: str
    license: str
    material: bool = True   # does this source's content appear in the output


@dataclass
class Resolution:
    output_license: Optional[str]
    conflict: bool
    reason: str
    attributions_required: List[str]


def resolve(sources: List[Source], public: bool = True) -> Resolution:
    """Compute the output license for a derived product."""
    # Only sources whose content appears in the output propagate obligations.
    contributors = [s for s in sources if s.material]
    if not contributors:
        return Resolution("CC0-1.0", False,
                          "No material contributors; output is unencumbered.", [])

    unknown = [s for s in contributors if s.license not in RANK]
    if unknown:
        names = ", ".join(s.name for s in unknown)
        return Resolution(None, True,
                          f"Unknown license on: {names}. Manual review required.", [])

    sa_licenses = {s.license for s in contributors if s.license in SHARE_ALIKE}

    # ODbL's share-alike does not fire on undistributed derivatives.
    if not public:
        sa_licenses.discard("ODbL-1.0")

    # Two incompatible copyleft terms -> no lawful output.
    for pair in INCOMPATIBLE_SA_PAIRS:
        if pair.issubset(sa_licenses):
            a, b = sorted(pair)
            return Resolution(None, True,
                              f"Incompatible share-alike terms: {a} vs {b}.", [])

    # A single copyleft term governs the whole output.
    if len(sa_licenses) == 1:
        governing = next(iter(sa_licenses))
    else:
        # No effective share-alike: the most restrictive attribution wins.
        governing = max((s.license for s in contributors), key=lambda l: RANK[l])

    # Every attribution-bearing source must still be credited.
    attributions = sorted({
        s.name for s in contributors
        if s.license not in ("CC0-1.0", "PDDL-1.0")
    })
    return Resolution(governing, False,
                      f"Output governed by {governing} (most-restrictive-wins).",
                      attributions)


if __name__ == "__main__":
    demo = [
        Source("national_landcover", "ODbL-1.0"),
        Source("study_area_mask", "CC-BY-SA-4.0", material=False),
        Source("place_names", "CC-BY-4.0"),
    ]
    result = resolve(demo, public=True)
    print(f"Output license : {result.output_license}")
    print(f"Conflict       : {result.conflict}")
    print(f"Reason         : {result.reason}")
    print(f"Must attribute : {', '.join(result.attributions_required)}")

In the demonstration, the CC BY-SA study-area mask is marked material=False because it only defines the clip boundary and contributes no content to the output. That single flag is the difference between a clean ODbL result and a false ODbL-versus-CC-BY-SA deadlock — which is exactly the mistake most manual reviews make.

Validation and pipeline integration

Exercise the resolver against the cases that matter before trusting it in a pipeline:

python resolve_derived_license.py

Add a pytest suite covering the decisive branches — absorption, deadlock, and the deployment-context escape hatch:

# tests/test_resolver.py
from resolve_derived_license import Source, resolve

def test_cc_by_absorbed_by_odbl():
    r = resolve([Source("a", "ODbL-1.0"), Source("b", "CC-BY-4.0")])
    assert r.output_license == "ODbL-1.0"
    assert not r.conflict

def test_incompatible_copyleft_deadlocks():
    r = resolve([Source("a", "ODbL-1.0"), Source("b", "CC-BY-SA-4.0")])
    assert r.conflict and r.output_license is None

def test_internal_use_drops_odbl_share_alike():
    r = resolve([Source("a", "ODbL-1.0"), Source("b", "CC-BY-SA-4.0")],
                public=False)
    assert r.output_license == "CC-BY-SA-4.0"

def test_immaterial_source_does_not_propagate():
    r = resolve([Source("data", "CC-BY-4.0"),
                 Source("mask", "CC-BY-SA-4.0", material=False)])
    assert r.output_license == "CC-BY-4.0"

Run this resolver at the join/clip stage of a data-orchestration DAG so a product that cannot be lawfully licensed fails before it is published — the same fail-fast posture used across the site’s spatial data schema linting in CI gates.

Long-term compliance best practices

  • Record materiality explicitly. For every input, store whether its content appears in the output. Clip masks, spatial filters, and reference grids are frequently non-material; treating them as contributors manufactures phantom conflicts.
  • Compute, never guess, the output license. Persist the resolver’s output — governing license, reason string, and attribution list — into the derivative’s catalog record so the decision is reproducible and auditable.
  • Store the full lineage chain. Keep every source’s fingerprint and license alongside the resolved output license; when a source is later relicensed, you can recompute affected derivatives instead of re-auditing by hand.
  • Distinguish internal from public at resolution time. Because ODbL share-alike is distribution-triggered, carry the deployment context into the resolver; promoting an internal product to public should re-run the resolution.
  • Resolve deadlocks by substitution, not suppression. When two copyleft sources conflict, replace one with a permissively licensed equivalent or obtain a relicense — never strip attribution or misdeclare the output license to force a publish.
  • Re-resolve after any new join. Adding a source to an existing product is a fresh derivation; run the resolver again rather than assuming the prior output license still holds.