Scoring License-Conflict Risk in a Data Inventory

A license-conflict risk score turns a sprawling spatial data inventory into a ranked triage queue by evaluating three weighted factors on every dataset — an unknown or unrecorded license, an incompatible combination with another layer, and a missing required attribution — and summing them into a single number that tells you which datasets to review before anything is published. Instead of auditing hundreds of layers uniformly, you point review effort at the handful carrying the most exposure.

License conflict is rarely a single catastrophic error; it is an accumulation of small, individually plausible gaps that surface only when a dataset is combined and distributed. Quantifying that exposure is the purpose of the Geospatial Risk Scoring Frameworks discipline, and it operationalises the risk surface described in Geospatial Data Licensing & Compliance Fundamentals. This guide gives you a runnable scorer that reads an inventory and returns a prioritised list.

The weighted factors

Three factors dominate license-conflict exposure, each carrying a different weight because each carries a different cost if it reaches production:

  • Unknown license (high weight). A dataset whose license is unrecorded, non-canonical, or outside the approved SPDX set cannot be reasoned about at all. It blocks any compatibility decision and is the single strongest predictor of a downstream problem.
  • Incompatible combination (highest weight). Two share-alike sources with conflicting copyleft terms — the classic ODbL versus CC BY-SA deadlock — produce a distributed product with no lawful license. This is weighted highest because it is both severe and irreversible once published.
  • Missing attribution (moderate weight). An attribution-requiring license with no recorded attribution string is a real but recoverable breach; it is usually fixable by regenerating credit blocks, so it carries a lower weight than the structural failures above.

Weights are configurable so a team can tune the score to its own risk appetite, and the public-versus-internal deployment context scales the whole score, because an internal-only layer carries far less exposure than a publicly distributed one.

Automated Python Implementation

The scorer below reads an inventory — a list of dataset records, each with a license, an attribution string, its combination partners, and a deployment context — and returns each dataset annotated with a factor breakdown and a total risk score, sorted highest-first. It is self-contained and uses only the standard library.

#!/usr/bin/env python3
"""Score license-conflict risk across a spatial data inventory.

Weighted factors: unknown license, incompatible combination, missing
attribution. Returns datasets ranked by descending risk score.
"""
from dataclasses import dataclass, field
from typing import Dict, List

# Canonical open licenses and which SPDX ids each remains compatible with.
COMPATIBLE: Dict[str, set] = {
    "CC0-1.0": {"CC0-1.0", "PDDL-1.0", "CC-BY-4.0", "CC-BY-SA-4.0",
                "ODbL-1.0", "OGL-UK-3.0"},
    "PDDL-1.0": {"CC0-1.0", "PDDL-1.0", "CC-BY-4.0", "CC-BY-SA-4.0",
                 "ODbL-1.0", "OGL-UK-3.0"},
    "CC-BY-4.0": {"CC0-1.0", "PDDL-1.0", "CC-BY-4.0", "CC-BY-SA-4.0",
                  "ODbL-1.0", "OGL-UK-3.0"},
    "CC-BY-SA-4.0": {"CC0-1.0", "PDDL-1.0", "CC-BY-4.0", "CC-BY-SA-4.0"},
    "ODbL-1.0": {"CC0-1.0", "PDDL-1.0", "CC-BY-4.0", "ODbL-1.0"},
    "OGL-UK-3.0": {"CC0-1.0", "PDDL-1.0", "CC-BY-4.0", "OGL-UK-3.0"},
}
# Licenses that impose an attribution obligation.
REQUIRES_ATTRIBUTION = {"CC-BY-4.0", "CC-BY-SA-4.0", "ODbL-1.0", "OGL-UK-3.0"}

# Tunable factor weights.
WEIGHTS = {
    "unknown_license": 40,
    "incompatible_combination": 50,
    "missing_attribution": 20,
}


@dataclass
class Dataset:
    name: str
    license: str
    attribution: str = ""
    combined_with: List[str] = field(default_factory=list)  # partner licenses
    public: bool = True


@dataclass
class RiskResult:
    name: str
    score: int
    factors: List[str]
    band: str


def score_dataset(ds: Dataset) -> RiskResult:
    """Compute the weighted license-conflict risk for one dataset."""
    factors: List[str] = []
    score = 0

    known = ds.license in COMPATIBLE
    if not known:
        score += WEIGHTS["unknown_license"]
        factors.append(f"unknown license '{ds.license}'")

    # Incompatible combination: any partner license this one cannot co-exist with.
    if known:
        for partner in ds.combined_with:
            if partner not in COMPATIBLE:
                score += WEIGHTS["unknown_license"]
                factors.append(f"combined with unknown license '{partner}'")
            elif partner not in COMPATIBLE[ds.license]:
                score += WEIGHTS["incompatible_combination"]
                factors.append(f"incompatible with '{partner}'")

    # Missing attribution where the license requires it.
    if ds.license in REQUIRES_ATTRIBUTION and not ds.attribution.strip():
        score += WEIGHTS["missing_attribution"]
        factors.append("required attribution missing")

    # Internal-only datasets carry reduced exposure.
    if not ds.public:
        score = int(score * 0.5)

    band = ("critical" if score >= 70 else "high" if score >= 40
            else "medium" if score >= 20 else "low")
    return RiskResult(ds.name, score, factors or ["no issues detected"], band)


def score_inventory(inventory: List[Dataset]) -> List[RiskResult]:
    """Score every dataset and return them ranked by descending risk."""
    results = [score_dataset(ds) for ds in inventory]
    return sorted(results, key=lambda r: r.score, reverse=True)


if __name__ == "__main__":
    inventory = [
        Dataset("national_roads", "ODbL-1.0",
                attribution="© Contributors, ODbL",
                combined_with=["CC-BY-SA-4.0"], public=True),
        Dataset("legacy_parcels", "proprietary-unknown",
                combined_with=[], public=True),
        Dataset("basemap_tiles", "CC-BY-4.0",
                attribution="", combined_with=["CC0-1.0"], public=True),
        Dataset("internal_grid", "CC-BY-SA-4.0",
                attribution="© Studio", combined_with=["ODbL-1.0"],
                public=False),
    ]
    for result in score_inventory(inventory):
        print(f"[{result.band:8}] {result.score:3d}  {result.name}")
        for factor in result.factors:
            print(f"            - {factor}")

The output ranks national_roads (ODbL combined with incompatible CC BY-SA, publicly distributed) at the top, drops internal_grid down because its identical incompatibility is internal-only and halved, and flags legacy_parcels for its unknown license and basemap_tiles for missing attribution. That ordering is exactly the triage queue a reviewer should work top-down.

Validation and pipeline integration

Run the scorer against the sample inventory and confirm the banding:

python score_license_risk.py

Add a pytest suite pinning the decisive scoring behaviours so a future weight change is a deliberate, reviewed act:

# tests/test_risk_scorer.py
from score_license_risk import Dataset, score_dataset

def test_incompatible_public_combination_is_critical():
    ds = Dataset("x", "ODbL-1.0", attribution="c",
                 combined_with=["CC-BY-SA-4.0"], public=True)
    assert score_dataset(ds).band in ("high", "critical")

def test_internal_context_halves_score():
    pub = score_dataset(Dataset("p", "ODbL-1.0", "c",
                                ["CC-BY-SA-4.0"], True)).score
    internal = score_dataset(Dataset("i", "ODbL-1.0", "c",
                                     ["CC-BY-SA-4.0"], False)).score
    assert internal == int(pub * 0.5)

def test_unknown_license_flagged():
    r = score_dataset(Dataset("u", "made-up-license"))
    assert any("unknown license" in f for f in r.factors)

def test_missing_attribution_flagged():
    r = score_dataset(Dataset("a", "CC-BY-4.0", attribution=""))
    assert any("attribution missing" in f for f in r.factors)

Wire the sweep into CI so a pull request that raises any dataset into the critical band fails the build, consistent with the site’s spatial data schema linting in CI gating approach:

# .github/workflows/license-risk.yml
name: License-Conflict Risk Sweep
on: [pull_request]
jobs:
  risk-sweep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Run risk scorer
        run: python score_license_risk.py

Long-term compliance best practices

  • Tune weights to your distribution reality. If nearly everything you publish is public, keep the incompatible-combination weight dominant; if most work is internal analysis, the context multiplier does more of the triage.
  • Treat unknown as blocking, not merely scored. An unrecorded license should never reach the top of a publish queue with a moderate score; resolve the license first, because every other factor depends on knowing what it is.
  • Re-score on every combination change. A new join introduces a new partner license and can flip a compatible dataset into an incompatible one; make scoring part of the pipeline rather than a periodic audit.
  • Keep the score history. Persist each sweep’s scores so trends are visible; a rising aggregate signals that new ingests are outpacing license hygiene.
  • Pair the score with an automated resolution. A high incompatible-combination score should link to the resolver that computes the lawful output license, so triage leads directly to a fix rather than a second manual investigation.
  • Escalate critical bands out of the pipeline. Route critical datasets to a named reviewer with a deadline, not just a log line; the highest-exposure entries are precisely the ones that get lost in noise.