Operational Context

A rights developer maintaining a multi-national collection has to answer one question for every object before it can be exposed: has copyright lapsed here, under the law that governs this work? The collection holds American imprints cleared at publication plus 95 years, European works cleared at the author’s death plus 70, and a long remainder of items whose only dating evidence is a single year with no indication of what that year even measures. A batch job that decides publication policy cannot ask a lawyer per record; it has to compute the term. The failure this page fixes is the one that ships quietly: a single hard-coded cutoff year — if year < 1929 — applied uniformly across the whole table, which is wrong for every jurisdiction it does not describe and stale the moment the calendar turns. This is the arithmetic layer beneath threshold tuning for public domain: the code that turns a jurisdiction plus a scrap of dating evidence into cleared, in_copyright, or undetermined, and never guesses when the evidence is not there.

Root Cause Analysis

The broken versions of this calculation almost always share three defects, and each has one root cause rather than a scatter of special cases.

First, a single cutoff year is jurisdiction-blind. A constant like PUBLIC_DOMAIN_BEFORE = 1929 encodes exactly one rule — the United States published-works bright line as it stood in one particular year — and silently applies it to French, German, and British works whose term is measured from the author’s death, not from publication. A life-plus-70 work by an author who died in 1980 is firmly in copyright regardless of when it was published, so a publication-year cutoff produces a confident, wrong cleared. Term is a property of jurisdiction and evidence type together; collapsing both to one number discards the information the decision actually depends on.

Second, a fixed cutoff goes stale. Copyright term is a rolling wall: on every January 1 a new cohort of works lapses. A cutoff frozen at build time means the pipeline stops clearing works the day after it ships and drifts further from the law each year until someone remembers to edit the constant. The wall has to be computed as today’s year minus a term length, so it advances on its own — the same discipline the parent threshold tuning stage applies to every cutoff it manages.

Third, missing evidence is treated as a clearance signal. A US rule needs a publication year; a life-plus-70 rule needs an author’s death year. A record that carries neither cannot be judged by either rule — but naive code reads the absent field as a zero or an empty string, and today.year - 0 > 95 clears it. The remedy across all three defects is the same: key the rules by jurisdiction in a frozen matrix, express each rule as a term length measured against the current year captured once per run, and make “the evidence this rule needs is absent” resolve to an explicit undetermined verdict that can never publish.

Canonical Solution

The design is a small term matrix keyed by jurisdiction. Each jurisdiction owns an ordered list of rules; each rule names the basis it counts from (a publication year or an author’s death year), the number of years in the term, and whether it applies only to works marked published. Evaluation walks the rules in order and applies the first one whose evidence the record actually supports — which is how the United States entry expresses “published works get publication + 95, everything else falls back to life + 70” without a branch in the caller. The comparison is always this_year - anchor > term, and this_year is captured once at the top of the run so a batch cannot straddle a year boundary mid-pass.

python
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date
from enum import Enum
from typing import Iterable, Iterator, Mapping

from pydantic import BaseModel


class Verdict(str, Enum):
    CLEARED = "cleared"              # term has lapsed — the work is public domain here
    IN_COPYRIGHT = "in_copyright"    # a rule applied and the term has not yet lapsed
    UNDETERMINED = "undetermined"    # no rule's evidence was present — never clears


class Basis(str, Enum):
    PUBLICATION = "publication"      # count the term from the year of publication
    DEATH = "death"                  # count the term from the author's year of death


@dataclass(frozen=True)
class TermRule:
    # One clearance rule: count `years` from the year named by `basis`. A term
    # *length*, never a fixed cutoff year, so the wall advances with today.year.
    basis: Basis
    years: int
    published_only: bool = False     # rule applies only to works marked published


# Ordered per jurisdiction; the first rule whose evidence the record supports wins.
_DEFAULT_MATRIX: dict[str, tuple[TermRule, ...]] = {
    "US": (
        TermRule(Basis.PUBLICATION, 95, published_only=True),  # published + 95
        TermRule(Basis.DEATH, 70),                             # unpublished → life + 70
    ),
    "EU": (TermRule(Basis.DEATH, 70),),   # harmonised EU default: life + 70
    "GB": (TermRule(Basis.DEATH, 70),),
    "CA": (TermRule(Basis.DEATH, 70),),   # life + 70 in Canada since 2022
}


@dataclass(frozen=True)
class TermMatrix:
    # Frozen so a run cannot mutate the rules underneath itself. Counsel adjusts a
    # term by editing this table, never the branching logic that consumes it.
    rules: Mapping[str, tuple[TermRule, ...]] = field(
        default_factory=lambda: dict(_DEFAULT_MATRIX)
    )

    def for_jurisdiction(self, code: str) -> tuple[TermRule, ...]:
        return self.rules.get(code.upper(), ())


class WorkEvidence(BaseModel):
    object_id: str
    jurisdiction: str
    publication_year: int | None = None
    author_death_year: int | None = None
    is_published: bool | None = None   # None = unknown, not False


def _anchor_for(rule: TermRule, rec: WorkEvidence) -> int | None:
    # The year this rule counts from, or None when the record lacks the evidence
    # the rule needs — in which case evaluation falls through to the next rule.
    if rule.published_only and rec.is_published is not True:
        return None
    if rule.basis is Basis.PUBLICATION:
        return rec.publication_year
    return rec.author_death_year


def clearance(rec: WorkEvidence, matrix: TermMatrix, this_year: int) -> Verdict:
    rules = matrix.for_jurisdiction(rec.jurisdiction)
    if not rules:
        return Verdict.UNDETERMINED          # no rule for this jurisdiction at all
    for rule in rules:
        anchor = _anchor_for(rule, rec)
        if anchor is None:
            continue                          # this rule's evidence is missing
        return (
            Verdict.CLEARED
            if this_year - anchor > rule.years
            else Verdict.IN_COPYRIGHT
        )
    return Verdict.UNDETERMINED               # a rule exists, but no evidence for it


def evaluate(
    records: Iterable[dict],
    matrix: TermMatrix = TermMatrix(),
    today: date | None = None,
) -> Iterator[tuple[str, Verdict]]:
    year = (today or date.today()).year       # single source of truth for the run
    for raw in records:
        rec = WorkEvidence(**raw)
        yield rec.object_id, clearance(rec, matrix, year)

Because each rule stores a term length and the comparison runs against year, captured once, the same code clears one further cohort every January 1 with no redeploy. And because a US published-works rule and a US unpublished fallback live in one ordered list, the caller never asks “is this published?” — the matrix answers it by which rule’s evidence is present. A record whose jurisdiction is unknown, or whose only rule needs a year the record does not carry, resolves to undetermined and is held, not cleared.

The copyright term decision path: jurisdiction and evidence select a rule, then today's year decides clearance Left to right, a work record passes three decision diamonds. Gate one (jurisdiction in matrix?) drops to an UNDETERMINED terminal on no. Gate two (evidence for a rule?) drops to the same UNDETERMINED terminal on no. Gate three (today's year minus anchor greater than the term?) exits up to a CLEARED public-domain terminal on yes and down to an IN_COPYRIGHT terminal on no. UNDETERMINED never clears. yes · rule found yes · anchor year no · not in matrix no · no evidence yes · term lapsed no · still in term Work record jurisdiction + dating evidence jurisdiction in matrix? evidence for a rule? year − anchor > term? CLEARED public domain here IN_COPYRIGHT term not yet lapsed UNDETERMINED held for review · never clears

The undetermined terminal is the load-bearing one: two distinct no-paths — no rule for the jurisdiction, and no evidence for the rule that exists — both land there, and neither can ever be mistaken for a clearance. That is the safe direction to be wrong, and it is what lets the same matrix feed a downstream policy step that publishes only on cleared and quarantines everything else, the same fail-safe contract worked through in handling orphan works in digital collections.

Edge Cases and Variants

  • US published + 95 vs. the pre-1929 bright line. The rolling comparison already implements the well-known “anything published before 1929” rule — it just recomputes the wall each year. As of a 2026 run, year - publication_year > 95 clears works published in 1930 or earlier and holds 1931; the bright-line date is not a constant to maintain, it is today.year - 95 derived on the fly.
  • US unpublished works. An unpublished US manuscript is not a publication-basis work at all; it clears at life + 70. The ordered matrix handles this without a caller branch: the published_only rule is skipped when is_published is not True, and evaluation falls through to the death-basis rule. A work with is_published = None (genuinely unknown) is not treated as published, so it never clears on the publication rule by accident.
  • EU and other life-plus-70 territories. EU, GB, and CA all resolve through the single death-basis rule. Add a jurisdiction by extending the matrix — for a life-plus-50 territory, add TermRule(Basis.DEATH, 50) under its code — never by loosening a default that governs other records.
  • Missing dates resolve to undetermined. A US record with is_published = True but no publication_year, or an EU record with no author_death_year, supports no rule’s evidence and returns undetermined. It is held for a documented search, exactly as an orphan is, rather than pushed through on a guessed date.
  • Anonymous, pseudonymous, and corporate works. These have no single author death to count from, so a life-plus-70 rule cannot apply even where dates exist. Model them as their own jurisdiction-qualified rule — for the EU, anonymous works run publication + 70 — by adding a publication-basis TermRule selected by a work-type flag, rather than forcing a death year the work does not have. Until that rule is added, such records correctly land in undetermined.
  • Strict vs. lenient input. WorkEvidence is a strict Pydantic v2 model: a non-integer year raises at construction. In a bulk backfill, wrap WorkEvidence(**raw) in try/except ValidationError and route the offender to undetermined; in an interactive re-check, surface the error to the operator instead of swallowing it.

Validation

The two invariants worth proving are that the boundary years compute correctly under each rule, and that a record with no supporting evidence never clears. A pinned today makes every boundary deterministic:

python
# test_copyright_term.py  ->  run with:  pytest -q test_copyright_term.py
from datetime import date
import pytest
from pydantic import ValidationError

MATRIX = TermMatrix()
TODAY = date(2026, 7, 17)

def _v(**over):
    base = {"object_id": "OBJ.1", "jurisdiction": "US", "is_published": True}
    rec = WorkEvidence(**{**base, **over})
    return clearance(rec, MATRIX, TODAY.year)

def test_us_publication_boundary():
    assert _v(publication_year=1930) is Verdict.CLEARED       # 2026-1930 = 96 > 95
    assert _v(publication_year=1931) is Verdict.IN_COPYRIGHT  # 2026-1931 = 95, not > 95

def test_eu_life_boundary():
    assert _v(jurisdiction="EU", is_published=None, author_death_year=1955) is Verdict.CLEARED
    assert _v(jurisdiction="EU", is_published=None, author_death_year=1956) is Verdict.IN_COPYRIGHT

def test_us_unpublished_falls_through_to_life_plus_70():
    # Not published, so the publication rule is skipped; the death rule applies.
    assert _v(is_published=False, author_death_year=1950) is Verdict.CLEARED

def test_missing_evidence_is_undetermined_never_cleared():
    assert _v(publication_year=None, author_death_year=None) is Verdict.UNDETERMINED

def test_unknown_jurisdiction_is_undetermined():
    assert _v(jurisdiction="ZZ", publication_year=1800) is Verdict.UNDETERMINED

def test_bad_year_raises_not_clears():
    with pytest.raises(ValidationError):
        WorkEvidence(object_id="OBJ.2", jurisdiction="US", publication_year="MCMXXX")

A green run confirms the ±1-year boundaries under both bases, that an unpublished US work routes to the life-plus-70 fallback, that absent evidence and an unknown jurisdiction both resolve to undetermined, and that a malformed year raises at the boundary rather than leaking into a clearance decision.

Standards Alignment

The three verdicts are the input to machine-readable rights, not the output. A cleared verdict selects a public-domain statement — a RightsStatements.org NoC-US or the Creative Commons Public Domain Mark — while in_copyright selects an InC URI and undetermined selects InC-RUU or a copyright-not-evaluated marker, the mapping worked through in assigning RightsStatements.org URIs in bulk. That statement URI then travels with the object: into a LIDO <lido:rightsWork> for export, and into the rights property of an IIIF Presentation 3.0 manifest for delivery, with runtime access enforced separately. The frozen term matrix relies on the immutable-configuration pattern in the Python dataclasses documentation; the surrounding clearance workflow is automating copyright status checks.

FAQ

Why compute a term length instead of storing a public-domain cutoff year?

Because a stored cutoff is correct for exactly one year and one jurisdiction. Copyright is a rolling wall: every January 1 another cohort lapses. Storing the term length — publication + 95, death + 70 — and comparing it against today.year, captured once per run, means the same code clears one more cohort each year with no redeploy. Counsel adjusts a term by editing the frozen matrix, never the branching logic.

How does one matrix handle US publication-plus-95 and EU life-plus-70 at once?

Each jurisdiction owns an ordered list of rules, and each rule names the basis it counts from and whether it is published-only. The US entry lists a publication + 95 rule for published works followed by a life + 70 fallback; the EU entry lists a single death + 70 rule. Evaluation applies the first rule whose evidence the record actually carries, so the caller never branches on jurisdiction or publication status — the matrix encodes both.

What happens when a record has no usable dates?

It resolves to undetermined, which never clears. A US rule needs a publication year and a life-plus-70 rule needs an author death year; a record carrying neither supports no rule, so the function returns undetermined rather than reading an absent field as zero and clearing it. The record is held for a documented search, the same safe default applied to an orphan work.

How do I add a jurisdiction with a different term?

Add an entry to the term matrix keyed by its code with the appropriate TermRuleTermRule(Basis.DEATH, 50) for a life-plus-50 territory, or a publication-basis rule for anonymous and corporate works. Because the matrix is a frozen dataclass consumed by a single comparison, a new jurisdiction is data, not code, and it cannot loosen the rule that governs any other jurisdiction’s records.