Operational Context

A rights-metadata engineer wires the institution’s publication policy into the IIIF layer, and the failure surfaces at validation time: a manifest carries a rights string the collections database happened to store — "© Trustees, all rights reserved", or a bespoke https://collections.example.org/license/17 URI — and a conformance checker rejects it, or worse, a downstream aggregator silently drops the record because the value is not a recognized rights statement. The engineer’s task is narrow and exacting: populate the IIIF Presentation 3.0 rights property with a single URI drawn only from the Creative Commons or RightsStatements.org vocabularies, and populate requiredStatement with a label/value attribution the viewer must display. Neither property controls who can see the pixels — that decision was already made in the rights metadata and licensing automation stage and is enforced at the image server. This page sits inside building IIIF Presentation manifests and covers exactly how to resolve a source rights value into a compliant rights URI and a correct requiredStatement language map, and when to omit rights entirely rather than emit something invalid.

Root Cause Analysis

Three distinct mistakes produce most non-conformant rights blocks, and each has a single underlying cause.

First, free text where a URI belongs. The Presentation 3.0 specification is unambiguous: if present, rights must be a string, that string must be a URI, and the URI must come from the Creative Commons license set or the RightsStatements.org statement set. A human-readable credit line, a copyright notice, or a rights label is not a URI and is not drawn from those vocabularies, so it is invalid. The cause is treating one field as two: the machine-actionable license identifier and the human-readable attribution are separate properties. The identifier goes in rights; the prose goes in requiredStatement.

Second, arbitrary or institution-local URIs. A URI that resolves — even one that returns a perfectly good rights page — still fails if its host is not creativecommons.org or rightsstatements.org. IIIF constrains the vocabulary, not merely the syntax, precisely so that a client anywhere in the world can map the value to a known license without dereferencing it. A local URI defeats that guarantee, which is why the correct move for an unmappable source value is to omit rights rather than invent one. Choosing the right target URI is the job of mapping Creative Commons licenses to IIIF rights and mapping RightsStatements.org to collection fields; this page assumes that mapping exists and validates its output.

Third, treating requiredStatement as access control. requiredStatement is a display obligation: a conforming viewer must show the label and value before or alongside the content. It is not a gate, a paywall, or an entitlement check. Engineers sometimes push an embargo or a “members only” condition into it and assume the viewer will withhold the image — it will not. Access is enforced upstream, by returning a restricted manifest or by refusing the Image API request, never by a string in the manifest body. Conflating display metadata with enforcement is how an “embargoed” asset ends up fully readable behind a polite attribution notice.

Canonical Solution

The routine below resolves a source rights value in one pass: it canonicalizes the candidate URI, matches it against the allowed Creative Commons and RightsStatements.org patterns, and either attaches a validated rights string or omits the property. Separately it builds the requiredStatement as a pair of IIIF language maps. A strict Pydantic v2 model makes an unmappable URI a hard failure at the boundary rather than a silent leak into the manifest.

python
from __future__ import annotations

import re
from urllib.parse import urlsplit, urlunsplit

from pydantic import BaseModel, ConfigDict, field_validator

# --- Allowed rights vocabularies -------------------------------------------
# IIIF Presentation 3.0 constrains `rights` to ONE URI drawn only from Creative
# Commons or RightsStatements.org. These patterns encode the canonical path
# shapes of each vocabulary; anything else is rejected, never passed through.
_CC_LICENSE = re.compile(
    r"^https?://creativecommons\.org/licenses/"
    r"(by|by-sa|by-nc|by-nd|by-nc-sa|by-nc-nd)/\d\.\d/$"
)
_CC_PUBLIC = re.compile(
    r"^https?://creativecommons\.org/publicdomain/(zero|mark)/1\.0/$"
)
_RS_VOCAB = re.compile(
    r"^https?://rightsstatements\.org/vocab/"
    r"(InC|InC-OW-EU|InC-EDU|InC-NC|InC-RUU|"
    r"NoC-CR|NoC-NC|NoC-OKLR|NoC-US|CNE|UND|NKC)/1\.0/$"
)


def canonical_rights_uri(uri: str) -> str:
    # Normalize scheme/host casing and force a single trailing slash so that
    # equivalent inputs collapse to one comparable string before matching.
    parts = urlsplit(uri.strip())
    host = parts.netloc.lower()
    path = parts.path if parts.path.endswith("/") else parts.path + "/"
    return urlunsplit((parts.scheme.lower(), host, path, "", ""))


def is_allowed_rights(uri: str) -> bool:
    c = canonical_rights_uri(uri)
    return bool(_CC_LICENSE.match(c) or _CC_PUBLIC.match(c) or _RS_VOCAB.match(c))


# --- requiredStatement (display attribution, NOT access control) ------------
# Both `label` and `value` are IIIF language maps: a BCP 47 language tag mapped
# to a list of plain strings, where each string renders as its own line.
LanguageMap = dict[str, list[str]]


class RequiredStatement(BaseModel):
    model_config = ConfigDict(extra="forbid")
    label: LanguageMap
    value: LanguageMap

    @field_validator("label", "value")
    @classmethod
    def non_empty_language_map(cls, v: LanguageMap) -> LanguageMap:
        if not v:
            raise ValueError("language map needs at least one language key")
        for tag, lines in v.items():
            if not tag or not lines:
                raise ValueError(f"empty tag or line list for {tag!r}")
        return v


def build_required_statement(
    attributions: list[str],
    lang: str = "en",
    label: str = "Attribution",
) -> RequiredStatement:
    # A resource may carry only ONE requiredStatement, so multiple credit lines
    # become multiple entries in the value array under a single shared label;
    # a conforming viewer renders them as separate lines, not separate blocks.
    return RequiredStatement(label={lang: [label]}, value={lang: list(attributions)})


class ManifestRights(BaseModel):
    model_config = ConfigDict(extra="forbid")
    rights: str | None = None
    required_statement: RequiredStatement | None = None

    @field_validator("rights")
    @classmethod
    def rights_from_allowed_vocab(cls, v: str | None) -> str | None:
        # Missing rights is legal — omit rather than fabricate. A present value
        # MUST canonicalize into the CC or RightsStatements.org vocabulary.
        if v is None:
            return None
        if not is_allowed_rights(v):
            raise ValueError(
                f"rights URI {v!r} is not a Creative Commons or "
                "RightsStatements.org URI; omit the property instead"
            )
        return canonical_rights_uri(v)

    def apply(self, manifest: dict) -> dict:
        # Attach `rights` only when it validated; requiredStatement is display
        # metadata and may stand alone even when no license URI is available.
        if self.rights is not None:
            manifest["rights"] = self.rights
        if self.required_statement is not None:
            manifest["requiredStatement"] = self.required_statement.model_dump()
        return manifest

Given a resolved license URI and one or more credit lines, wiring the block into a manifest skeleton is a single call:

python
block = ManifestRights(
    rights="http://creativecommons.org/licenses/by-sa/4.0/",
    required_statement=build_required_statement([
        "Provided by the Example Museum of Art.",
        "Photograph © 2026 J. Rivera, licensed CC BY-SA 4.0.",
    ]),
)

manifest = block.apply({
    "@context": "http://iiif.io/api/presentation/3/context.json",
    "id": "https://example.org/iiif/obj-42/manifest",
    "type": "Manifest",
})

The resulting manifest fragment is exactly what a viewer consumes: a single machine-readable rights URI and a requiredStatement whose language maps carry the attribution the viewer is obligated to display.

json
{
  "rights": "http://creativecommons.org/licenses/by-sa/4.0/",
  "requiredStatement": {
    "label": { "en": ["Attribution"] },
    "value": {
      "en": [
        "Provided by the Example Museum of Art.",
        "Photograph © 2026 J. Rivera, licensed CC BY-SA 4.0."
      ]
    }
  }
}

The decision the validator encodes — resolve a source value, test it against the allowed vocabulary, then either set both properties or omit rights — is the branch that trips up most engineers, so it is worth seeing laid out.

Resolving a source rights value: embargo gate, then allowed-vocabulary gate Left to right. A source rights record enters an embargo decision. On the not-published branch it drops to a dark terminal where the manifest is withheld and access is enforced upstream. On the published branch it reaches a vocabulary decision. On the allowed-URI branch it reaches a success terminal that sets both rights and requiredStatement. On the unmappable branch it reaches a caution terminal that omits rights and attaches requiredStatement only. Source rights value → compliant manifest block (or a considered omission) Source rights record published? not embargoed CC / RS vocab? Set rights URI + requiredStatement fully compliant block machine-readable license + attribution Omit rights; requiredStatement only still valid, no license URI never invent a local URI Manifest withheld access enforced upstream, not here no · embargoed yes · live yes · CC / RS URI no · unmappable

Edge Cases and Variants

  • In-copyright vs. Creative Commons vs. public domain. A CC license URI belongs only on a work the institution has actually licensed under those terms. An in-copyright work with no open license takes a RightsStatements.org statement — InC, InC-EDU, or the orphan-oriented InC-RUU — never a CC URI. A true public-domain asset takes the Creative Commons Public Domain Mark (publicdomain/mark/1.0/) or, for a rights waiver, CC0 (publicdomain/zero/1.0/); use the RightsStatements.org NoC-* “No Copyright” statements when the institution’s position is that no copyright applies. The validator accepts all of these because it matches the vocabulary, not the semantics — getting the right URI for the work is the mapping upstream.
  • Multiple attributions. Because a resource carries only one requiredStatement, combine credit lines into the value language map’s array rather than repeating the property. A viewer renders each array element on its own line. If a photographer, a lender, and the holding institution all need crediting, that is three strings under one Attribution label, not three statements.
  • Multilingual attribution. Add parallel language keys — {"en": [...], "fr": [...]} — and the viewer picks the one matching the user’s locale, falling back per the IIIF language-map rules. Use the "none" key only for a string with no meaningful language, such as a bare institution name or a symbol.
  • Embargoed items stay dark. When the asset is under embargo, the correct output is no public manifest at all (or a restricted one), decided by setting date-based embargo triggers. Do not publish a readable manifest and rely on requiredStatement to hold viewers back — it cannot.
  • Strict vs. lenient ingestion. In a batch backfill, wrap ManifestRights(...) in try/except ValidationError and route the offending record to review with rights unset, so one bad legacy value never blocks the run; in an interactive editor, surface the error so a curator supplies a correct URI. Either way the manifest never ships an invalid rights string.
  • CSV vs. XML vs. API source. The resolved URI may arrive from a CSV export column, a LIDO <rightsType> element, or a collections API field. Only the extraction differs; feed whatever you resolve into is_allowed_rights and the same vocabulary gate applies. The actual manifest assembly is covered in generating IIIF v3 manifests with Python.

Validation

Prove the two invariants that matter — an out-of-vocabulary value can never reach rights, and the requiredStatement serializes as valid language maps — with an assert-based test that needs no viewer:

python
# test_manifest_rights.py  ->  run with:  pytest -q test_manifest_rights.py
import pytest
from pydantic import ValidationError


def test_cc_and_rightsstatements_uris_are_accepted():
    for uri in [
        "http://creativecommons.org/licenses/by/4.0/",
        "https://creativecommons.org/publicdomain/zero/1.0/",
        "http://rightsstatements.org/vocab/InC/1.0/",
        "https://rightsstatements.org/vocab/NoC-US/1.0/",
    ]:
        assert ManifestRights(rights=uri).rights.endswith("/")


def test_free_text_rights_is_rejected():
    with pytest.raises(ValidationError):
        ManifestRights(rights="All rights reserved")


def test_arbitrary_local_uri_is_rejected():
    with pytest.raises(ValidationError):
        ManifestRights(rights="https://collections.example.org/license/17")


def test_missing_trailing_slash_is_normalized():
    mr = ManifestRights(rights="https://creativecommons.org/licenses/by/4.0")
    assert mr.rights == "https://creativecommons.org/licenses/by/4.0/"


def test_required_statement_carries_multiple_lines():
    rs = build_required_statement(["Provided by Example Museum.", "CC BY 4.0."])
    dumped = rs.model_dump()
    assert dumped["label"] == {"en": ["Attribution"]}
    assert dumped["value"]["en"] == ["Provided by Example Museum.", "CC BY 4.0."]


def test_rights_may_be_omitted_but_statement_kept():
    block = ManifestRights(required_statement=build_required_statement(["Anon lender."]))
    out = block.apply({"type": "Manifest"})
    assert "rights" not in out                 # omitted, not invented
    assert out["requiredStatement"]["value"]["en"] == ["Anon lender."]

A green run confirms that only CC and RightsStatements.org URIs survive into rights, that free text and local URIs raise at the boundary, that trailing-slash noise canonicalizes, that multiple credit lines land in one language map, and that omitting rights leaves a valid attribution-only block.

Standards Alignment

The rights and requiredStatement properties are defined by the IIIF Presentation API 3.0: rights “must be drawn from the set of Creative Commons license URIs, the RightsStatements.org rights statement URIs, or those added via the extension mechanism,” and requiredStatement is a label/value pair that clients “must display” — a presentation obligation, not an access restriction. The allowed license identifiers are the canonical URIs published by Creative Commons, and the copyright-status statements are those minted by RightsStatements.org; the twelve standardized statement codes cover In Copyright, No Copyright, and Other states. Selecting which URI a given work warrants is a rights-modeling decision handled in the rights metadata and licensing automation section — see mapping Creative Commons licenses to IIIF rights and mapping RightsStatements.org to collection fields — while this page is solely concerned with encoding that decision correctly in the manifest.

FAQ

Can I put my institution’s own license URL in the rights property?

No. IIIF Presentation 3.0 constrains rights to the Creative Commons and RightsStatements.org vocabularies specifically so any client can interpret the value without dereferencing it. A local URL — even one that resolves to a real rights page — is non-conformant. If your source value does not map to those vocabularies, omit rights entirely and carry the human-readable terms in requiredStatement instead.

Does requiredStatement stop unauthorized users from seeing the image?

No. requiredStatement is a display obligation: a conforming viewer must show the attribution, but it never withholds content. Access control lives upstream — you return a restricted or absent manifest, or the Image API refuses the tile request. Pushing an embargo condition into requiredStatement leaves the asset fully readable behind a notice, which is exactly the failure to avoid.

How do I credit several parties in one manifest?

A resource may hold only one requiredStatement, so put each credit line as a separate string in the value language map’s array under a single shared label such as Attribution. Viewers render array entries on separate lines. Repeating the property or nesting multiple statements is invalid.

Use a RightsStatements.org statement — commonly InC (In Copyright), or a more specific variant like InC-EDU or the orphan-oriented InC-RUU — never a Creative Commons URI, since CC URIs assert a license the rightsholder has actually granted. Reserve the Creative Commons Public Domain Mark and CC0 for public-domain and waived-rights material respectively.