Weighting Licence Risk by Dataset Exposure
Multiply each dataset’s intrinsic licence risk by how exposed it is — how many published products depend on it, whether those products leave the organisation, and how many external parties consume them — because an unresolved licence on a layer nobody ships is a housekeeping task, and the same licence on the layer under your public API is an incident waiting for a date.
Risk frameworks that score datasets in isolation produce work lists ordered by how bad a licence looks rather than by how much trouble it can cause. The result is predictable: teams remediate the alarming-looking layers in the archive while the ordinary-looking CC-BY layer feeding six public products goes unexamined because nothing about it stands out. Exposure weighting fixes the ordering. This guide sits under geospatial risk scoring frameworks, within Geospatial Data Licensing & Compliance Fundamentals.
Automated Python Implementation
Exposure is a property of the dependency graph, not of the dataset record, so the computation starts by walking the products that consume each layer.
#!/usr/bin/env python3
"""Weight licence risk by how exposed each dataset is."""
import json
import math
from collections import defaultdict
# How far a product's audience amplifies the risk of its inputs.
AUDIENCE_WEIGHT = {
"internal": 1.0,
"partner": 2.5,
"public_download": 4.0,
"public_api": 5.0,
}
def build_dependency_index(products):
"""Map dataset id -> list of products that consume it, transitively."""
direct = defaultdict(list)
for product in products:
for dataset_id in product["inputs"]:
direct[dataset_id].append(product)
return direct
def exposure(dataset_id, index):
"""Exposure factor: audience reach, damped by count so it does not run away."""
consumers = index.get(dataset_id, [])
if not consumers:
return 0.5 # held but not shipped — real, but not urgent
reach = max(AUDIENCE_WEIGHT[p["audience"]] for p in consumers)
# Logarithmic in the number of dependants: the tenth consumer adds
# much less than the second, which matches how remediation cost behaves.
breadth = 1.0 + math.log(len(consumers), 4)
return reach * breadth
def prioritise(datasets, products):
"""Return datasets ordered by weighted risk, with the reasoning attached."""
index = build_dependency_index(products)
rows = []
for dataset in datasets:
factor = exposure(dataset["id"], index)
rows.append({
"id": dataset["id"],
"licence": dataset.get("spdx"),
"intrinsic_risk": dataset["risk"],
"exposure": round(factor, 2),
"weighted": round(dataset["risk"] * factor, 2),
"dependants": len(index.get(dataset["id"], [])),
"widest_audience": max(
(p["audience"] for p in index.get(dataset["id"], [])),
key=lambda a: AUDIENCE_WEIGHT[a], default="none"),
})
return sorted(rows, key=lambda r: -r["weighted"])
if __name__ == "__main__":
import sys
config = json.load(open(sys.argv[1], encoding="utf-8"))
for row in prioritise(config["datasets"], config["products"])[:20]:
print(f"{row['weighted']:8.2f} {row['id']:<32} "
f"risk={row['intrinsic_risk']:<5} x{row['exposure']:<5} "
f"({row['dependants']} dependants, {row['widest_audience']})")
Two modelling choices in that code are worth defending.
Reach uses the maximum audience, not the sum. A dataset feeding one public API and four internal notebooks is exposed at public-API level; the internal consumers add nothing to the worst case. Summing audiences would let a large number of harmless internal uses outweigh a single public one, which inverts the ordering the metric exists to produce.
Breadth is logarithmic. The second product depending on a layer materially increases the cost of changing its licence; the twentieth barely does, because the remediation work is already a coordinated project either way. A linear count makes a widely used internal reference layer dominate the list purely by being popular.
Validation and Pipeline Integration
The properties worth asserting are about ordering rather than about absolute values, because the absolute numbers have no meaning outside the comparison.
def test_public_exposure_outranks_higher_intrinsic_risk():
datasets = [
{"id": "archive", "risk": 9.1},
{"id": "addresses", "risk": 6.8},
]
products = [{"id": "api", "inputs": ["addresses"], "audience": "public_api"}]
ranked = prioritise(datasets, products)
assert ranked[0]["id"] == "addresses"
def test_unshipped_data_is_not_zero_risk():
datasets = [{"id": "archive", "risk": 9.1}]
ranked = prioritise(datasets, [])
assert ranked[0]["weighted"] > 0
def test_breadth_saturates():
datasets = [{"id": "base", "risk": 5.0}]
few = [{"id": f"p{i}", "inputs": ["base"], "audience": "internal"} for i in range(2)]
many = [{"id": f"p{i}", "inputs": ["base"], "audience": "internal"} for i in range(40)]
assert prioritise(datasets, many)[0]["weighted"] < \
4 * prioritise(datasets, few)[0]["weighted"]
The second test guards the mistake that looks most reasonable at design time. Setting unshipped datasets to zero exposure removes them from the queue entirely, and the archive is exactly where unresolved licences accumulate — they simply should not be at the top.
Run the prioritisation after each catalogue sync rather than in a gate. It is a planning output, not a pass or fail, and treating it as a gate produces a build that fails because somebody published a new product.
Keeping the Dependency Graph Honest
Everything here rests on knowing which products consume which datasets, and that is the part organisations reliably do not have. A hand-maintained list of inputs per product is out of date within a quarter, and its errors are asymmetric: forgotten dependencies make datasets look safer than they are.
Three sources make the graph self-maintaining, in increasing order of effort and reliability.
Build manifests. Any pipeline that produces a product already names its inputs somewhere — a configuration file, a DAG definition, a script’s argument list. Emitting that list as a machine-readable artifact during the build is usually a few lines and produces a graph that cannot drift, because it is generated by the thing it describes.
Lineage records. Where spatial data lineage and provenance tracking is already in place, the dependency graph is a projection of it and needs no separate maintenance at all.
Access logs. The weakest source, and the only one available for consumption paths nobody modelled — an analyst querying a table directly, a partner pulling a layer from a shared store. Logs will not tell you what a dataset was used for, but they will reveal that a layer believed to be unused has six consumers, which is the discovery that matters.
Whichever source is used, record the date the graph was built alongside the priorities derived from it. A queue computed against a six-month-old graph is a queue about a six-month-old organisation.
Long-Term Compliance Best Practices
- Recompute exposure when products change, not when datasets do. The dataset’s licence rarely moves; its exposure moves every time somebody ships something.
- Show the reasoning next to the number. A row reading “risk 6.8 × 5.0, one public API consumer” is actionable; a bare weighted score invites argument about the weights.
- Keep intrinsic risk and exposure separately visible. They are remediated differently: intrinsic risk by resolving a licence, exposure by changing what a product depends on.
- Do not let exposure zero out. An archive with no consumers still carries risk, because “no consumers” is a statement about today.
- Review the audience weights annually, not per incident. Adjusting them after a specific problem fits the metric to the last event rather than to the next one.
Related
- Geospatial Risk Scoring Frameworks — the intrinsic dimensions this guide multiplies
- Scoring License-Conflict Risk in a Data Inventory — the pairwise conflict score that feeds intrinsic risk
- Spatial Data Lineage & Provenance Tracking — the graph that makes exposure computable without hand maintenance
- Aggregating Compliance Metrics Across a Dataset Inventory — reporting the resulting queue without losing the drill-down