Operational Context
A Python automation engineer owns the nightly job that decides what the delivery layer is allowed to serve for every digitized object, and the recurring failure is narrow and unforgiving: an asset whose embargo lapsed at midnight is still hidden the next morning, or — the direction you cannot walk back — a donor-restricted image is exposed a day before its release date. Each record carries a release_date (the day an embargo ends) and the delivery layer enforces a discrete access tier: dark serves nothing, metadata_only serves the catalog record but withholds the image, and open serves the full asset. This page resolves the exact task of turning that single date into the correct tier — the directive the delivery layer reads — and re-deriving it on schedule so the tier tracks the calendar without a curator or a redeploy. It runs inside the parent implementing embargo workflows stage, consumes the timezone-normalized boundary produced by setting date-based embargo triggers, and emits the tier that the downstream IIIF Image API configuration and the wider rights metadata and licensing automation pipeline enforce.
Root Cause Analysis
Tier routing goes wrong along four concrete vectors, and each traces to a single root cause rather than a spread of unrelated bugs.
First, a frozen comparison date. If the cutoff is computed once — at import time, at deploy time, or captured into a module-level constant — the comparison never advances. A release_date that falls the day after the build stays “in the future” forever, so the asset is never promoted to open. The tier must be compared against the current calendar day, resolved at the moment the job runs.
Second, caching the tier instead of recomputing it. Storing a decided tier on the record and trusting it on later passes means the record only ever changes when something writes to it. Embargo expiry is the opposite of an event: nothing writes to the record when the clock crosses the boundary. The tier must be recomputed from release_date on every run so the passage of time alone is enough to flip it.
Third, date-versus-datetime and timezone pitfalls. An embargo is expressed in whole days, but exports arrive with datetime values, some timezone-aware and some naive. Comparing a datetime against a date raises TypeError in Python, and a timezone-aware timestamp near midnight can resolve to the wrong calendar day. The same UTC normalization discipline covered in setting date-based embargo triggers applies here, then the value must be reduced to a bare calendar date before it is judged.
Fourth, an unsafe default. Premature exposure is unrecoverable: once an image has been served openly it may be cached, scraped, or downloaded, and no later correction retracts it. So an undecidable or in-progress embargo must fall to the most restrictive tier, and only a record that positively clears its release date may reach open. Enforcing the record contract at the boundary with a strict Pydantic v2 model keeps a malformed date from ever reaching the routing decision.
Canonical Solution
The solution captures today once per run, reduces every release_date to a bare calendar date, and maps the record to exactly one AccessTier — returning a DeliveryDirective the delivery layer can act on directly. Because the tier is recomputed from the date on every pass rather than read from a cached field, an asset promotes itself to open on the first run on or after its release day. The annotated implementation runs against any iterable of raw dicts.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from enum import Enum
from typing import Iterable, Iterator
from pydantic import BaseModel, field_validator
class AccessTier(str, Enum):
DARK = "dark" # deliver nothing: no image, no metadata
METADATA_ONLY = "metadata_only" # catalog record visible, image withheld
OPEN = "open" # full asset: image + metadata delivered
@dataclass(frozen=True)
class DeliveryDirective:
# The delivery layer reads exactly these fields; the tier names the policy and the
# two booleans are the enforceable form the IIIF Image API and manifest layer act on.
object_id: str
tier: AccessTier
serve_image: bool
serve_metadata: bool
class ObjectRecord(BaseModel):
object_id: str
release_date: date | None = None # day the embargo lapses; None == no embargo
metadata_public_during_embargo: bool = False
@field_validator("release_date", mode="before")
@classmethod
def to_calendar_date(cls, v: str | date | None) -> date | None:
# Guard the date-vs-datetime pitfall in one place: a datetime compared to a date
# raises TypeError, and a tz-aware timestamp near midnight can name the wrong day.
# Collapse every value to a bare calendar date so the boundary is judged in whole
# days — the unit an embargo is actually expressed in.
if v is None:
return None
if isinstance(v, datetime): # datetime is a subclass of date; check first
return v.date()
if isinstance(v, date):
return v
return date.fromisoformat(str(v)[:10]) # tolerate an ISO datetime string, keep the date
def route(rec: ObjectRecord, today: date) -> DeliveryDirective:
# `today` is captured ONCE per run and passed in — never date.today() inline — so a
# frozen build-time constant can't silently pin the comparison, and every record in the
# pass is judged against the same calendar day.
if rec.release_date is None or rec.release_date <= today:
# No embargo, or the release date has arrived / lapsed → fully open. The boundary
# is inclusive: the asset opens ON its release day, not the day after.
return DeliveryDirective(rec.object_id, AccessTier.OPEN, True, True)
# release_date is strictly in the future → still embargoed. Restrict, and expose the
# catalog record only when policy explicitly allows it. Anything undecided stays dark.
if rec.metadata_public_during_embargo:
return DeliveryDirective(rec.object_id, AccessTier.METADATA_ONLY, False, True)
return DeliveryDirective(rec.object_id, AccessTier.DARK, False, False)
def reroute_all(rows: Iterable[dict], today: date | None = None) -> Iterator[DeliveryDirective]:
# The nightly scheduler calls this; `today` defaults to the run's real calendar day.
# Because the tier is RECOMPUTED from release_date every run — never cached on the
# record — an asset flips to OPEN on the first run on or after its release day with no
# redeploy and no curator action. Re-running the same day yields identical directives.
today = today or date.today()
for raw in rows:
yield route(ObjectRecord(**raw), today)Every record resolves to exactly one tier, each tier maps to a fixed pair of delivery booleans, and the pass is idempotent: running it twice on the same day produces the same directives, so the scheduler can retry a failed run safely. The only thing that changes a record’s tier between runs is the calendar crossing its release_date.
The two open paths — no embargo and lapsed embargo — converge on the same tier, while every still-embargoed record falls to dark unless policy positively opens its metadata. That asymmetry is the safety property: openness requires a positive reason, restriction is the default.
Edge Cases and Variants
- No embargo. A
Nonerelease_datemeans the object was never embargoed and routes straight toopen. This is distinct from an indefinite embargo, which must never be modeled as a null date. - Indefinite embargo. A restriction with no known end date must not be represented by leaving
release_dateempty, or it would open immediately. Model it with a far-future sentinel date or a separateindefiniteflag that forcesdarkregardless of the clock — a manual curatorial release, never a date comparison, lifts it. - Timezone and date-only inputs. The validator reduces every
datetimeto a baredate, sidestepping theTypeErrorfrom comparing adatetimeto adate. When source timestamps are timezone-aware, normalize them to UTC before they reach this model, exactly as in setting date-based embargo triggers, so the calendar day is resolved consistently. - Donor restriction overrides the date. A deed-of-gift clause can hold an object dark past its computed release date. Model that as a separate gate that can only lower the tier — never raise it — so a date-cleared record can still be pinned to
dark, but a donor allowance can never open an in-copyright work. - Staggered media versus metadata. Some agreements release the catalog record before the image.
metadata_public_during_embargoexpresses exactly that split, routing tometadata_onlywhile the image stays withheld until the release date lapses. - CSV vs. XML vs. API input.
reroute_allis agnostic to source: feed it acsv.DictReader, aniterparsegenerator that callselem.clear()per record, or a paged API cursor. For large tables, wrap the source in the fixed-size chunking from handling large CSV batches without memory leaks so peak memory stays flat.
Validation
Prove the invariants that matter — a not-yet-released asset stays dark, a lapsed embargo flips open, and re-running is idempotent — with an assert-based test that needs no database:
# test_embargo_routing.py -> run with: pytest -q test_embargo_routing.py
from datetime import date
TODAY = date(2026, 7, 17)
def _row(**over):
return {"object_id": "OBJ.1", **over}
def test_not_yet_released_stays_dark():
d = route(ObjectRecord(**_row(release_date="2027-01-01")), TODAY)
assert d.tier is AccessTier.DARK
assert not d.serve_image and not d.serve_metadata # nothing leaves the store
def test_metadata_only_during_embargo_when_flagged():
d = route(ObjectRecord(**_row(release_date="2027-01-01",
metadata_public_during_embargo=True)), TODAY)
assert d.tier is AccessTier.METADATA_ONLY
assert d.serve_metadata and not d.serve_image # record visible, image withheld
def test_lapsed_embargo_flips_open_on_the_boundary_day():
d = route(ObjectRecord(**_row(release_date="2026-07-17")), TODAY) # release day == today
assert d.tier is AccessTier.OPEN # inclusive: opens ON the day
assert d.serve_image and d.serve_metadata
def test_no_embargo_is_open():
assert route(ObjectRecord(**_row()), TODAY).tier is AccessTier.OPEN
def test_datetime_input_is_reduced_to_a_date():
rec = ObjectRecord(**_row(release_date="2026-07-17T23:30:00+00:00"))
assert rec.release_date == date(2026, 7, 17) # time stripped, no TypeError
assert route(rec, TODAY).tier is AccessTier.OPEN
def test_reroute_is_idempotent():
rows = [_row(release_date="2027-01-01"), _row(object_id="OBJ.2")]
first = [d.tier for d in reroute_all(rows, TODAY)]
second = [d.tier for d in reroute_all(rows, TODAY)]
assert first == second == [AccessTier.DARK, AccessTier.OPEN]A green run confirms that a future release date holds the asset dark, that a metadata allowance exposes only the record, that the boundary is inclusive so an asset opens on its release day, that a datetime input never raises a comparison error, and that re-running the same day is a no-op — the properties the scheduler depends on.
Standards Alignment
The tier this routing produces is an internal access-control decision, not itself a rights statement, and the two must stay distinct in the exports. While an object is dark or metadata_only, its IIIF Presentation API 3.0 manifest still carries the appropriate RightsStatements.org URI — for example https://rightsstatements.org/vocab/InC/1.0/ — in its rights property; the manifest describes the copyright status, while runtime enforcement of the tier is handled separately by the IIIF Authorization Flow API rather than by any manifest field. When route promotes an asset to open, the pipeline regenerates the manifest and revises the delivered rights assertion, and the tier’s serve_image flag becomes the gate the IIIF Image API configuration enforces — a change that must be reflected downstream when caching IIIF Image API responses at the edge, so a lapsed embargo is not defeated by a stale cached tile. In LIDO, the underlying embargo boundary belongs in <lido:rightsWork> as a normalized <lido:rightsDate>, and assigning the correct statement URI at each tier is covered in mapping RightsStatements.org to collection fields. See the Python datetime documentation for the date-versus-datetime semantics this routing depends on.
FAQ
Why route by comparing against today captured per run instead of a stored tier?
Embargo expiry is not an event — nothing writes to the record when the clock crosses the release date. If the tier is cached on the record, it only changes when something updates it, so a lapsed embargo stays restricted indefinitely. Recomputing the tier from release_date against the run’s real calendar day means the passage of time alone promotes the asset, with no redeploy and no curator action.
Should the boundary be inclusive or exclusive?
Inclusive. An embargo that ends on 2026-07-17 is over on that day, so release_date <= today opens the asset on its release day rather than the day after. Using strict < delays every release by a full day, which is invisible in a spot check but shows up as assets that “should be live” consistently lagging their stated date.
What is the difference between the dark and metadata-only tiers?
dark serves nothing — neither the image nor the catalog record leaves the store. metadata_only exposes the catalog record while withholding the image, which fits agreements that release descriptive metadata before the asset itself. A still-embargoed record only reaches metadata_only when metadata_public_during_embargo is explicitly set; otherwise it falls to dark, the restrictive default.
How do I handle an embargo with no known end date?
Never leave release_date empty for it — a null date routes straight to open. Model an indefinite restriction with a far-future sentinel date or a separate flag that forces dark regardless of the clock, so only a deliberate curatorial action releases it. A date comparison should never open an object whose restriction has no defined end.
Related
- Implementing Embargo Workflows — parent embargo stage
- Setting Date-Based Embargo Triggers — timezone-safe boundary evaluation
- Configuring the IIIF Image API — delivery layer that enforces the tier
- Caching IIIF Image Responses at the Edge — invalidating a stale tier at release
- Mapping RightsStatements.org to Collection Fields — the URI carried at each tier