Within the broader rights metadata mapping and licensing automation pipeline, this stage owns one narrow, high-consequence job: taking an asset that already carries an open-license assertion and turning that free-text prose into a canonical Creative Commons URI, then dispatching the asset to the endpoint its license actually permits. It sits downstream of the copyright decision engine — a work only reaches this router once automating copyright status checks has established that the object is open rather than protected — and upstream of manifest generation, where the resolved URI becomes the rights value a public viewer reads. The router is the point where “the donor said this was CC BY-NC” becomes https://creativecommons.org/licenses/by-nc/4.0/, and where a statement it cannot resolve is quarantined rather than published under a guessed license.
The problem is deceptively simple and unforgiving in exactly one place. A museum DAMS ingests rights notes from legacy CMS exports, donor contracts, and digitization logs, and those notes are written by humans: “Creative Commons Attribution”, “CC-BY 4.0”, “cc by-nc-nd”, “Public Domain (CC0)”. Normalizing that variety into a fixed set of URIs is string work — until you notice that cc by is a substring of cc by-nc-nd, at which point a naive if key in statement loop mis-tags every restricted derivative as plain attribution and silently strips the non-commercial and no-derivatives terms a rightsholder insisted on. The engine on this page resolves that ambiguity deterministically, refuses to invent a license for anything it does not recognize, and never falls back to CC0 — because defaulting to the most permissive license is the one failure mode that surrenders rights the institution had no authority to give away.
Prerequisites
Before wiring the license router, confirm the following are in place:
- Python 3.9+ for the PEP 604
|union syntax (CCLicense | None),list[...]/dict[...]generics, and theasyncioprimitives used in the batch processor. pydantic(v2) for the payload contract — this page uses the v2 API (field_validator,Field,model_dump); see the pydantic v2 documentation for themode="before"validator signature.- A version-controlled license registry. The map from normalized strings to URIs must be a reviewed artifact, not a literal scattered through the code, because the 3.0-versus-4.0 distinction is load-bearing and changes only under change control.
- Normalized copyright status upstream. The router assumes each asset has already been judged open by the parent decision engine; it resolves which open license applies, not whether the work is open.
- Two or more reachable destinations at dispatch time: an open-delivery target that feeds IIIF manifest generation, and an internal-archive or curator-review target for licenses whose terms forbid open reuse.
- Interchange conformance for the surviving payload. Resolved URIs must land in the LIDO
lido:rightsWorkblock and the IIIF Presentation API 3.0rightsproperty without further transformation.
License Registry & Payload Schema
The registry is the single point of truth for what the router may emit. Every canonical URI it can produce is enumerated as a closed Enum, so a resolved value is guaranteed to be a real Creative Commons statement and never a hand-typed string. The CCLicense members below pin the 4.0 versions of the six-license suite plus the CC0 public-domain dedication; a 3.0 registry, when an institution needs it, is a parallel enum, never a mutated version string.
| Normalized key | Canonical URI | Allows open reuse | Routing destination |
|---|---|---|---|
cc0 |
.../publicdomain/zero/1.0/ |
yes (dedication) | iiif_manifest |
cc by |
.../licenses/by/4.0/ |
yes | iiif_manifest |
cc by-sa |
.../licenses/by-sa/4.0/ |
yes | iiif_manifest |
cc by-nc |
.../licenses/by-nc/4.0/ |
yes (non-commercial) | iiif_manifest |
cc by-nd |
.../licenses/by-nd/4.0/ |
yes (no derivatives) | iiif_manifest |
cc by-nc-sa |
.../licenses/by-nc-sa/4.0/ |
yes (NC + SA) | iiif_manifest |
cc by-nc-nd |
.../licenses/by-nc-nd/4.0/ |
yes (NC + ND) | iiif_manifest |
| (unrecognized) | — | — | dead_letter_queue |
The distinction the table cannot show — and the one that governs the code — is specificity. cc by is a literal substring of six of the seven keys, so resolution can never test keys in arbitrary order; it must try the longest key that matches first. That single ordering rule is the correctness core of this entire page.
Architecture and Data Flow
The router decouples ingestion, normalization, resolution, and dispatch into discrete async stages so that a slow downstream write never blocks upstream reads and one malformed record cannot cancel its siblings. A semaphore-bounded worker pool keeps a batch from overwhelming the source CMS, a strict validation gate normalizes each rights statement before resolution, and a thread-safe dead-letter queue captures anything the registry cannot resolve. The same normalized payload that feeds IIIF manifest generation also feeds LIDO export, so resolution happens exactly once per asset regardless of how many downstream schemas consume it.
Step-by-Step Implementation
Production implementations rely on asyncio for controlled concurrency and pydantic for schema enforcement. The engine is split into three responsibilities — the payload contract, the resolver, and the async pipeline — each presented in turn and then assembled in run_batch.
1. Define and normalize the payload contract
The ingestion boundary coerces raw fields into a strict model and normalizes the rights statement before any resolution runs. Lowercasing and collapsing “creative commons” to “cc” happens inside a field_validator(mode="before"), so every record the resolver sees is already in canonical string form and the resolver never has to reason about casing or spelling variants.
import asyncio
import hashlib
import logging
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, field_validator
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cc_license_router")
class CCLicense(str, Enum):
CC0 = "https://creativecommons.org/publicdomain/zero/1.0/"
CC_BY = "https://creativecommons.org/licenses/by/4.0/"
CC_BY_SA = "https://creativecommons.org/licenses/by-sa/4.0/"
CC_BY_NC = "https://creativecommons.org/licenses/by-nc/4.0/"
CC_BY_ND = "https://creativecommons.org/licenses/by-nd/4.0/"
CC_BY_NC_SA = "https://creativecommons.org/licenses/by-nc-sa/4.0/"
CC_BY_NC_ND = "https://creativecommons.org/licenses/by-nc-nd/4.0/"
class AssetRightsPayload(BaseModel):
asset_id: str = Field(..., min_length=3, pattern=r"^[A-Z0-9\-]+$")
raw_rights_statement: str
rights_source: str = "legacy_cms"
embargo_until: str | None = None
@field_validator("raw_rights_statement", mode="before")
@classmethod
def normalize_statement(cls, v: str) -> str:
# Runs before type validation so downstream resolution always sees a
# lowercase, 'cc'-prefixed string regardless of how the CMS spelled it.
return v.strip().lower().replace("creative commons", "cc")
class RoutedAsset(BaseModel):
asset_id: str
license_uri: str
routing_destination: str
validation_status: str = "passed"
metadata_hash: str | None = None2. Resolve the license with longest-match-first ordering
This is the step that makes or breaks the router. Because cc by is a substring of every by-* variant, resolution sorts the registry keys by length descending and returns on the first containment match. A statement of "cc by-nc-nd 4.0" therefore matches the seven-character-longer cc by-nc-nd key before it can ever match the shorter cc by, and its non-commercial and no-derivatives terms survive intact. Reverse the sort and the router silently downgrades every restricted work to open attribution.
class LicenseRouter:
def __init__(self, max_concurrency: int = 10):
self.semaphore = asyncio.Semaphore(max_concurrency)
self.dead_letter_queue: list[dict[str, Any]] = []
self._mapping = {
"cc0": CCLicense.CC0,
"cc by": CCLicense.CC_BY,
"cc by-sa": CCLicense.CC_BY_SA,
"cc by-nc": CCLicense.CC_BY_NC,
"cc by-nd": CCLicense.CC_BY_ND,
"cc by-nc-sa": CCLicense.CC_BY_NC_SA,
"cc by-nc-nd": CCLicense.CC_BY_NC_ND,
}
def _resolve_license(self, statement: str) -> CCLicense | None:
# Match the most specific key first: "cc by" is a substring of
# "cc by-nc-nd", so test longest keys before shortest to avoid
# mis-tagging every by-* variant as plain CC BY.
for key in sorted(self._mapping, key=len, reverse=True):
if key in statement:
return self._mapping[key]
return None3. Route each asset and dead-letter the unresolved
process_asset acquires the semaphore, resolves the statement, and dispatches by reuse permission: any license containing by grants some form of reuse and flows to IIIF manifest generation, while anything else (a bare restriction the registry did not recognize) is held in an internal archive. An unresolved statement raises, and the handler appends the raw record to the dead-letter queue rather than substituting a default license. A content hash over asset_id and the resolved URI gives every routing decision an auditable fingerprint.
async def process_asset(self, payload: AssetRightsPayload) -> RoutedAsset | None:
async with self.semaphore:
try:
license_uri = self._resolve_license(payload.raw_rights_statement)
if not license_uri:
raise ValueError("Unrecognized CC license pattern")
destination = (
"iiif_manifest"
if "by" in payload.raw_rights_statement
else "internal_archive"
)
metadata_hash = hashlib.sha256(
f"{payload.asset_id}:{license_uri.value}".encode()
).hexdigest()
return RoutedAsset(
asset_id=payload.asset_id,
license_uri=license_uri.value,
routing_destination=destination,
metadata_hash=metadata_hash,
)
except Exception as exc:
# Never substitute a default license: quarantine for review.
logger.warning("Routing failed for %s: %s", payload.asset_id, exc)
self.dead_letter_queue.append({
"asset_id": payload.asset_id,
"error": str(exc),
"raw_statement": payload.raw_rights_statement,
})
return None
async def run_batch(self, payloads: list[AssetRightsPayload]) -> list[RoutedAsset]:
tasks = [self.process_asset(p) for p in payloads]
# return_exceptions=True keeps one failure from cancelling the batch;
# keep only successfully routed assets.
results = await asyncio.gather(*tasks, return_exceptions=True)
return [r for r in results if isinstance(r, RoutedAsset)]For CSV ingest the payloads are built row-by-row from a streamed reader; for an XML or API source they are constructed from parsed elements. The contract is identical in every case — the only variant is the adapter that fills raw_rights_statement, which is why normalization lives in the model rather than in each caller. Streaming rather than materializing a full export keeps memory bounded on large batches, a constraint examined in depth by the CC-BY-NC-ND tagging guide.
IIIF and LIDO Schema Alignment
A resolved URI is only useful if it lands unchanged in the schemas a public viewer and an aggregator read. IIIF Presentation API 3.0 expects the manifest rights property to hold exactly one resolvable license or rights-statement URI — the value this router emits drops in directly, with access enforcement delegated to the separate IIIF Auth API rather than encoded in the manifest. LIDO requires the license to sit inside a lido:rightsWork block alongside lido:rightsType and lido:rightsHolder; the same license_uri populates the rightsType reference. Because both schemas consume the identical normalized payload, resolution runs once and fans out, which keeps the IIIF and LIDO representations of an asset from ever disagreeing about its license. The database side of that fan-out — writing the URI into a bounded CMS column without truncation — is covered under mapping LIDO to internal databases.
Rights and Access Routing
The routing decision at this stage is a pure function of the resolved license, and it is deliberately conservative. A license that grants reuse — anything in the CC BY family, including the non-commercial and no-derivatives variants — is cleared for open IIIF delivery, because even CC BY-NC-ND permits public display with attribution. A record the registry cannot resolve is never treated as open: it goes to the dead-letter queue for a curator to adjudicate, and the asset stays dark until a human confirms its license. The one license that demands special care is CC0. It is the most permissive value in the registry, so a router that ever defaults to it — on a parse failure, a missing field, or an unmatched string — would publish works into the public domain that the institution may not own outright. That is why the resolver returns None rather than a fallback, and why the failure path quarantines instead of guessing. Temporal restrictions that gate even an open-licensed asset until a release date are handled separately by implementing embargo workflows, and the rolling public-domain cutoff that decides whether a work is eligible for CC0 in the first place is the subject of threshold tuning for public domain.
Verification and Testing
The router’s correctness rests almost entirely on resolution ordering and the fail-safe, so the tests pin exactly those behaviours. Each case is a plain asyncio.run invocation with assert checks — no framework required — and the suite should be run before any batch touches production endpoints.
def test_longest_match_wins():
router = LicenseRouter()
# The restricted variant must NOT collapse to plain CC BY.
assert router._resolve_license("cc by-nc-nd 4.0") is CCLicense.CC_BY_NC_ND
assert router._resolve_license("cc by 4.0") is CCLicense.CC_BY
def test_unrecognized_is_dead_lettered():
async def _run():
router = LicenseRouter()
payload = AssetRightsPayload(
asset_id="OBJ-001",
raw_rights_statement="All Rights Reserved",
)
result = await router.process_asset(payload)
assert result is None
assert router.dead_letter_queue[0]["asset_id"] == "OBJ-001"
asyncio.run(_run())
def test_reuse_routes_to_iiif():
async def _run():
router = LicenseRouter()
payload = AssetRightsPayload(
asset_id="OBJ-002",
raw_rights_statement="Creative Commons BY-SA 4.0",
)
result = await router.process_asset(payload)
assert result.license_uri == CCLicense.CC_BY_SA.value
assert result.routing_destination == "iiif_manifest"
asyncio.run(_run())The first test is the guard against the substring bug; if resolution order ever regresses it fails immediately. The second proves the fail-safe holds — an unrecognized statement produces no RoutedAsset and lands in the dead-letter queue. The third confirms normalization and reuse routing agree end to end. For a live dry run, call run_batch against a sample of real identifiers and tally routing_destination values plus the dead-letter count before allowing process_asset to write to any real endpoint.
In This Section
One focused guide goes deeper on the most error-prone corner of this router:
- Automating CC-BY-NC-ND Tagging in Python — a memory-safe, generator-driven pipeline for the most restricted CC license, where the non-commercial and no-derivatives terms must survive bulk synchronization intact and never be flattened to plain attribution.
FAQ
Why sort the registry keys by length instead of using a regex?
Because containment ordering is the actual requirement and a length-descending sort states it directly. cc by is a substring of six other keys, so the resolver must test the longest matching key first; sorting self._mapping by len descending and returning on the first hit guarantees cc by-nc-nd wins over cc by. A regex could encode the same precedence with anchored alternation, but it hides the ordering intent that a reviewer most needs to see, and it is far easier to break silently when a new license variant is added.
Why does an unrecognized statement dead-letter instead of defaulting to a license?
Because every default is a wrong answer waiting to happen, and the least-wrong default — CC0 — is the most dangerous. Substituting any license for an unparseable statement asserts a legal fact the pipeline cannot support, and defaulting to the most permissive one publishes works into the public domain the institution may not own. Returning None and quarantining the record keeps the asset dark until a curator supplies a real license, which is the only safe failure mode.
How do I handle Creative Commons 3.0 versus 4.0 statements?
Keep them in separate registries and never mutate a version string. CC 3.0 and 4.0 differ in their treatment of moral rights and database rights, so collapsing them to a single URI mislabels the license. Add a second enum whose members carry the /3.0/ URIs and select the registry from an explicit version token parsed off the statement; if no version is present, treat the statement as unresolved and dead-letter it rather than assuming 4.0.
Why route CC BY-NC and CC BY-ND to IIIF delivery at all?
Because both still permit public display with attribution — the non-commercial and no-derivatives terms restrict downstream reuse, not the museum’s own presentation. The reuse check keys on the presence of by, which every reuse-granting license carries, so all six BY-family licenses plus CC0 clear open delivery. Access enforcement for anything narrower is delegated to the IIIF Auth API, not encoded in this routing decision.
What stops one malformed record from failing the whole batch?
run_batch calls asyncio.gather with return_exceptions=True, so an exception raised inside process_asset is returned as a value rather than propagating and cancelling the sibling tasks. The final list comprehension keeps only genuine RoutedAsset results, and any record that raised has already been captured in the dead-letter queue by the handler, so a single bad statement costs one quarantined record and nothing more.
Related
- Rights Metadata Mapping & Licensing Automation — parent pipeline overview
- Automating Copyright Status Checks — upstream open/protected verdict
- Automating CC-BY-NC-ND Tagging in Python — most-restricted CC license
- Mapping Creative Commons Licenses to IIIF Rights — canonical CC URIs for manifests
- Implementing Embargo Workflows — time-based access release
- Mapping LIDO to Internal Databases — persisting the rights block