Within the broader rights metadata mapping and licensing automation pipeline, most of the rights logic reasons about one thing: the copyright status of the work itself. Donor terms are a second, independent constraint source. A gift can be fully in the public domain by copyright and still be closed to the public for a decade because the deed of gift says so. This stage turns the legal language attached to an accession — the donor agreement, the deed of gift, and any signed addenda — into a structured set of restrictions the rights pipeline can evaluate at publish time, right beside the copyright determination it already computes.
The problem is that these terms arrive as prose, not as fields. A registrar reads “the collection shall remain closed to research access until January 1, 2035, and all reproductions must credit the Whitfield Family Collection” and files the paper. Nothing in that sentence is machine-readable, so nothing downstream can enforce it. Six years later a bulk publication job exposes the media because the copyright check returned “no known copyright” and no other signal existed to stop it. This guide specifies the restriction model that captures those clauses, the extraction pass that lifts them out of agreement text, the conflict resolution that reconciles them with copyright status, and the publish-time gate that enforces the most restrictive outcome.
Workflow Context
Copyright status answers “may we, legally, reproduce this work?” Donor terms answer a different question: “did we, contractually, promise not to?” The two are orthogonal. A donor can gift a nineteenth-century photograph — unambiguously public domain — under a deed that embargoes public access until the donor’s death, requires a specific credit line on every reproduction, and forbids commercial use of the images. None of those constraints live in the copyright record; all of them bind the institution.
Treating donor terms as a first-class constraint source means the rights engine evaluates two inputs and takes the more restrictive result. Where automating copyright status checks computes an access posture from copyright law, this stage computes a parallel posture from the gift agreement, and a merge step reconciles them. A record is only as open as its most restrictive applicable constraint — copyright can never loosen a donor embargo, and a donor grant can never override an in-copyright restriction. The extraction of the raw clauses themselves, including span offsets back into the source document, is the job of the deep dive on parsing deed-of-gift restrictions; this page consumes those clauses and turns them into enforced policy.
Prerequisites
Before wiring donor-term enforcement into the pipeline, confirm the following are in place:
- Python 3.9+ for PEP 604
|unions andmatchstatements used in the conflict resolver. pydantic(v2) for the restriction contract; this page uses the v2 API (model_config,field_validator,model_validate,model_dump) — see the Pydantic documentation for details.- A digitized agreement corpus — deed-of-gift and donor-agreement text extracted to UTF-8, ideally with clause boundaries preserved. Scanned paper deeds route through automating OCR metadata extraction first.
- A resolved copyright posture per object from the copyright stage, expressed as a RightsStatements.org URI plus an access tier.
- An access-tier vocabulary the whole pipeline agrees on — this guide uses
dark,gated,authenticated,public, ordered from most to least restrictive. - A curator review queue — any restriction whose interpretation is uncertain must land in a human queue rather than being guessed.
- Downstream enforcement points that honor the tier: the IIIF Presentation API 3.0
rightsandrequiredStatementblocks, and the access-control layer in front of media delivery.
Schema & Field Reference
The restriction is the atomic unit of this stage: one clause of one agreement becomes one Restriction. A single gift typically produces several — an embargo, a credit requirement, and a non-commercial use limit are three separate restrictions sharing a source_clause lineage back to the signed document. Modeling each as a discrete, typed object is what lets the conflict resolver reason about them independently.
| Field | Type | Constraint | Purpose |
|---|---|---|---|
type |
Literal["access","credit","embargo","use"] |
required | The category of constraint the clause imposes |
scope |
str |
required | What the restriction binds — object, collection, or media |
value |
str |
required, non-empty | The operative parameter (a date, a credit line, a use limit) |
source_clause |
str |
required, non-empty | Verbatim or cited clause text for audit and appeal |
review_required |
bool |
default False |
Routes ambiguous clauses to a curator before enforcement |
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
RestrictionType = Literal["access", "credit", "embargo", "use"]
class Restriction(BaseModel):
"""One machine-actionable constraint lifted from a gift agreement."""
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
type: RestrictionType
scope: str
value: str
source_clause: str
review_required: bool = False
@field_validator("value", "source_clause")
@classmethod
def not_blank(cls, value: str) -> str:
# A restriction with no operative value or no clause lineage is
# unenforceable and unauditable — reject it at construction.
if not value:
raise ValueError("value and source_clause must be non-empty")
return value
@field_validator("scope")
@classmethod
def known_scope(cls, value: str) -> str:
if value not in {"object", "collection", "media"}:
raise ValueError(f"unknown scope: {value!r}")
return valueWith extra="forbid", an extraction pass that emits an unexpected key — a symptom of a drifting parser — fails loudly at the model boundary instead of silently writing an unenforceable restriction.
Step-by-Step Implementation
1. Build a restriction taxonomy
Before extracting anything, fix the closed vocabulary the rest of the stage depends on. Four type values cover the vast majority of deed language: access (who may see the record or media), embargo (a time-boxed access restriction that lifts on a date), credit (an attribution string that must accompany reproductions), and use (limits on how reproductions may be used, e.g. non-commercial). Keeping the taxonomy small and closed is deliberate: an open-ended set of restriction types cannot be enforced uniformly.
TAXONOMY: dict[str, str] = {
"access": "who may view the record or its media",
"embargo": "a time-boxed access restriction lifting on a date",
"credit": "an attribution line required on reproductions",
"use": "a limit on how reproductions may be used",
}
def is_enforceable(restriction_type: str) -> bool:
"""Only taxonomy members map to a concrete enforcement action."""
return restriction_type in TAXONOMYEdge cases. Deeds occasionally impose obligations that are real but not machine-enforceable at publish — a promise to exhibit the gift once per decade, say. These do not belong in the taxonomy; record them as curatorial notes, not Restriction objects, so the enforcement gate never has to reason about a constraint it cannot act on.
2. Extract terms from agreement text
The extraction pass reads clause text and emits candidate restrictions. A deterministic classifier keyed on legal boilerplate is the reliable core; anything it cannot confidently type is emitted with review_required=True rather than dropped. The heavy lifting of locating clause boundaries and character spans is covered in parsing deed-of-gift restrictions — here we consume already-segmented clauses.
import re
EMBARGO_RE = re.compile(r"closed\b.*?until\s+(?P<date>\w+\s+\d{1,2},\s+\d{4})", re.I)
CREDIT_RE = re.compile(r"must credit\s+(?:the\s+)?(?P<credit>[A-Z][\w &']+)", re.I)
def extract_restrictions(clause: str) -> list[Restriction]:
"""Lift zero or more candidate restrictions from one clause."""
found: list[Restriction] = []
if m := EMBARGO_RE.search(clause):
found.append(Restriction(
type="embargo", scope="collection",
value=m.group("date"), source_clause=clause,
))
if m := CREDIT_RE.search(clause):
found.append(Restriction(
type="credit", scope="media",
value=m.group("credit").strip(), source_clause=clause,
))
# A clause that names a restriction word but matches no pattern is
# flagged for a human rather than silently ignored.
if not found and re.search(r"restrict|shall not|prohibit", clause, re.I):
found.append(Restriction(
type="access", scope="object",
value="unparsed restriction", source_clause=clause,
review_required=True,
))
return foundEdge cases. For structured agreements (some large institutions capture terms in a form-driven intake system that emits JSON), skip regex entirely and model_validate the fields directly. For XML deeds encoded in an archival EAD <accessrestrict> element, read the element text as the clause. For free-text OCR output, normalize whitespace and rejoin hyphenated line breaks before matching, or an embargo date split across two lines will be missed.
3. Model the restrictions as validated objects
Every candidate passes through Restriction.model_validate so the taxonomy, scope vocabulary, and non-empty invariants are enforced uniformly, whatever the extraction source. This mirrors the strict Pydantic contract used across ingestion: validation is the single gate, not a per-parser afterthought.
from pydantic import ValidationError
def model_clause_batch(
clauses: list[str],
) -> tuple[list[Restriction], list[dict[str, object]]]:
"""Validate every extracted candidate; split clean from rejected."""
valid: list[Restriction] = []
rejected: list[dict[str, object]] = []
for clause in clauses:
for candidate in extract_restrictions(clause):
try:
valid.append(Restriction.model_validate(candidate.model_dump()))
except ValidationError as exc:
rejected.append(
{"clause": clause, "errors": exc.errors(include_url=False)}
)
return valid, rejectedEdge cases. An embargo value must parse as a real date before enforcement; the Restriction model deliberately keeps value a string so extraction never fails on a malformed date, but the enforcement gate in step 5 converts and rejects unparseable dates into the review queue. Keeping coercion out of the model and inside the gate keeps the audit lineage — the verbatim clause — intact.
4. Resolve conflicts with copyright status
Now the two constraint sources meet. The copyright stage supplies an access tier; the donor restrictions supply their own. The rule is a total order over tiers and a single operation: take the most restrictive. A donor access or embargo restriction can only tighten the copyright tier, never loosen it, and vice versa.
from datetime import date
TIER_ORDER = ["public", "authenticated", "gated", "dark"]
def _rank(tier: str) -> int:
return TIER_ORDER.index(tier)
def donor_tier(restrictions: list[Restriction], today: date) -> str:
"""Derive the access tier the donor terms alone demand."""
tier = "public"
for r in restrictions:
match r.type:
case "embargo":
lifts = date.fromisoformat_lenient(r.value)
# An active embargo goes dark; a lapsed one imposes nothing.
candidate = "dark" if lifts and today < lifts else "public"
case "access":
candidate = "gated"
case _:
candidate = "public" # credit/use don't gate visibility
if _rank(candidate) > _rank(tier):
tier = candidate
return tier
def effective_tier(copyright_tier: str, restrictions: list[Restriction],
today: date) -> str:
"""The record is only as open as its most restrictive constraint."""
donor = donor_tier(restrictions, today)
return copyright_tier if _rank(copyright_tier) >= _rank(donor) else donorEdge cases. credit and use restrictions do not change the tier — a public record with a mandatory credit line is still public — but they must still travel with the record so the delivery layer can render the required attribution and enforce the use limit. Treat them as riders on the tier, not inputs to it. (date.fromisoformat_lenient here stands in for the tolerant date parser from step 3; a strict date.fromisoformat plus a try/except routing failures to review is the production form.)
5. Enforce at publish
The publish gate is the single choke point where a record’s computed tier becomes reality. It refuses to publish any object carrying a restriction still flagged review_required, and it stamps the effective tier plus any credit rider onto the outgoing record.
def publish_gate(object_id: str, copyright_tier: str,
restrictions: list[Restriction], today: date) -> dict[str, object]:
"""Compute the enforced access decision for one object at publish time."""
pending = [r for r in restrictions if r.review_required]
if pending:
return {"object_id": object_id, "action": "hold",
"reason": "curator review required", "count": len(pending)}
tier = effective_tier(copyright_tier, restrictions, today)
credits = [r.value for r in restrictions if r.type == "credit"]
return {
"object_id": object_id,
"action": "publish",
"access_tier": tier,
"required_statement": " · ".join(credits) or None,
}Edge cases. The gate must fail closed: if the copyright tier is missing, treat it as dark, never public. An object held for review is not a failure — it is the system correctly refusing to guess about a legal obligation. Embargoes are time-dependent, so the gate must run again as dates pass; wiring a lapsed embargo to automatically re-open a record is the subject of implementing embargo workflows.
Rights and Access Routing
The routing consequence of this stage is the one thing every other rights component must respect: a donor restriction can override an otherwise-open copyright status. When automating copyright status checks returns “no known copyright” or a public-domain determination — a posture that would normally route the object to the public tier with full-resolution media — an active donor embargo drags the effective tier down to dark, and a donor access restriction pins it at gated. The copyright signal is not wrong; it is simply not the only signal.
Concretely, the merge in step 4 is the routing decision. A photograph that is public domain by copyright but embargoed by deed until 2035 routes to dark today and will route to public the day the embargo lapses — provided the gate re-runs. Credit and use riders travel alongside the tier: the required attribution string is written into the IIIF requiredStatement so every viewer renders it, and a non-commercial use restriction is recorded on the record even when the tier is public. The one direction the merge never allows is loosening: an in-copyright object with an open deed stays gated by copyright, because a donor cannot grant rights the institution never held.
Verification and Testing
Prove the merge and the gate before pointing enforcement at live records. The assert-based test below exercises the decisive case — public-domain copyright overridden by an active donor embargo — plus the review-hold and credit-rider paths.
from datetime import date
def test_donor_enforcement() -> None:
embargo = Restriction(
type="embargo", scope="collection",
value="2035-01-01", source_clause="closed until January 1, 2035",
)
credit = Restriction(
type="credit", scope="media",
value="Whitfield Family Collection", source_clause="must credit ...",
)
# 1. Public-domain copyright is overridden by an active donor embargo.
decision = publish_gate("2018.7", "public", [embargo, credit], date(2026, 7, 17))
assert decision["action"] == "publish"
assert decision["access_tier"] == "dark"
assert decision["required_statement"] == "Whitfield Family Collection"
# 2. After the embargo lapses, the same object routes public again.
later = publish_gate("2018.7", "public", [embargo, credit], date(2036, 1, 2))
assert later["access_tier"] == "public"
# 3. An unresolved restriction holds publication rather than guessing.
unclear = Restriction(
type="access", scope="object", value="unparsed restriction",
source_clause="the collection shall not be reproduced save by ...",
review_required=True,
)
held = publish_gate("2018.8", "public", [unclear], date(2026, 7, 17))
assert held["action"] == "hold"
if __name__ == "__main__":
test_donor_enforcement()
print("donor enforcement OK")Run a dry pass over the agreement corpus that extracts and merges but writes nothing, so you can reconcile how many objects each donor tier and the review queue would receive before any record changes visibility:
python -m rights.donor_terms --agreements deeds/ --dry-run --report tiers.csvA clean report shows every embargoed object at dark, every credit rider attached, and a review-queue count you can staff against. Any public-domain object landing at public while its deed names an embargo is a missed extraction to fix before enforcement goes live.
Where to Go Deeper
- Parsing Deed-of-Gift Restrictions — the clause-segmentation and span-extraction layer that feeds this stage, covering how to locate
<accessrestrict>boundaries, recover character offsets back into the signed document for audit, and handle the messy OCR and multi-clause sentences that a naive regex pass misses.
FAQ
How is a donor restriction different from a copyright status?
They answer different questions. Copyright status is a legal fact about the work — whether it may lawfully be reproduced — and is computed from authorship, dates, and jurisdiction. A donor restriction is a contractual obligation the institution accepted in exchange for the gift. A work can be perfectly free of copyright and still closed by deed. Because they are independent, the rights engine evaluates both and enforces the more restrictive.
Can a donor agreement ever make a record more open than copyright allows?
No. The merge only tightens. A donor cannot grant rights the institution does not hold, so an in-copyright object stays gated by its copyright posture even under the most permissive deed. Donor terms can pull an otherwise-open record closed — an embargo over public-domain material — but never push a copyright-restricted record open.
What happens when an extraction pass cannot confidently type a clause?
It emits a restriction flagged review_required=True, and the publish gate refuses to publish any object carrying such a flag until a curator resolves it. The system never guesses about a legal obligation. A held object is the correct outcome, not an error — it is the pipeline declining to expose media on an unverified clause.
How do credit and use restrictions differ from access and embargo?
Access and embargo restrictions change the tier — who may see the record or its media. Credit and use restrictions do not gate visibility: a public record with a mandatory credit line is still public. Instead they ride along with the record so the delivery layer can render the required attribution in the IIIF requiredStatement and honor limits like non-commercial use, whatever the tier.
An embargoed record should have re-opened but did not. Why?
The publish gate is time-dependent and must be re-run as dates pass; a decision computed while the embargo was active does not update itself. Schedule the gate to re-evaluate objects with future-dated embargoes so a lapsed embargo re-routes the record to its copyright-derived tier. The scheduling pattern lives in the embargo workflows guide.
Related
- Rights Metadata Mapping & Licensing Automation — parent rights pipeline overview
- Parsing Deed-of-Gift Restrictions — clause extraction deep dive
- Automating Copyright Status Checks — the parallel constraint source
- Implementing Embargo Workflows — time-based access re-routing
- Routing Creative Commons Licenses — license-to-IIIF rights mapping