Preservation Action Logging: A Machine-Verifiable Event Record for Every Archival Object
Preservation action logging is the provenance backbone of the parent OAIS-Compliant Digital Preservation Architecture: the discipline of recording every action taken against a preserved object as a durable, machine-verifiable event, so the archive can prove — not merely assert — what happened to each bitstream, when, by whom, and with what outcome. Where PREMIS Metadata Mapping defines the shape of a provenance record, this stage is the write path that fills it: it captures the transient facts a preservation action produces at the exact moment it occurs and commits them to storage that cannot later be edited. Its scope is deliberately narrow and load-bearing — PREMIS event emission patterns, immutable append-only ledger design, and fixity re-validation scheduling — and everything else in the architecture writes into it. When Long-Term Storage Architecture replicates an AIP across tiers, or Digital Preservation Security Policies evaluate an access request, the evidence that it happened correctly is an event this layer emitted. For archivists and Python automation engineers, the mandate is to make logging a non-optional side effect of every action, structured and attributed at the point of execution, never reconstructed after the fact.
The PREMIS Event Model and the Event Lifecycle
A preservation event is not a log line; it is a first-class PREMIS entity that binds three others together. Every Event links to the Object it acted on and the Agent that performed it, and carries its own typed identity, timestamp, and outcome. That triangular linkage — Object, Event, Agent — is what an ISO 16363 auditor reconstructs to establish an unbroken chain of custody. An event with no resolvable agent is an unattributed action; an event with no linked object is a fact about nothing. The lifecycle of a single event is short and strict: an action begins, the emitter captures its inputs, the action runs, the emitter records the outcome, and the completed event is sealed into the ledger where it becomes immutable evidence. There is no update state — a correction is itself a new event that references the one it supersedes.
The core discipline is choosing the right eventType from a controlled vocabulary and recording the outcome detail that makes that event independently verifiable years later. A fixityCheck that records only “pass” is nearly worthless; one that records the algorithm, the digest compared against, and the digest recomputed is replayable evidence. The table below catalogues the event types this layer must emit, the trigger that produces each, and the outcome detail that is mandatory rather than optional.
| PREMIS eventType | Trigger | Required outcome detail |
|---|---|---|
capture |
A source object is digitised or received from a producer | Capture device or source system, operator agent, initial byte count |
messageDigestCalculation |
A fixity digest is first computed for an object | Algorithm identifier (SHA-256), computed digest, bitstream size |
validation |
A file is checked against a format or schema profile | Tool and version (JHOVE, jsonschema), profile identifier, pass/fail with error list |
ingestion |
An object crosses the ingest boundary into managed storage | Source SIP identifier, crosswalk version, initial fixity digest |
replication |
An AIP is copied to another storage node or tier | Target node identifier, post-copy fixity match, transfer agent |
fixityCheck |
A scheduled or triggered integrity re-verification runs | Algorithm identifier, stored digest, recomputed digest, pass/fail |
migration |
A format is normalised to a preservation master | Source and target format, tool version, fixity of both source and target |
deletion |
An object or copy is deauthorised and removed | Authorising agent, policy or rights basis, tombstone identifier retained |
The two obligations that carry the most weight are attribution and outcome specificity. Every row above must resolve to an Agent — a person, a piece of software, or an organization — and every outcome must be detailed enough that a reader who never saw the action can decide whether it succeeded. A deletion in particular is never a silent DELETE: the object’s bitstream may go, but a tombstone event referencing its former identifier stays forever, because an auditor must be able to see that a removal was authorised, not that a record simply vanished.
From Action to Sealed Ledger Entry
The hardest concept on this page is the path a single action travels from the moment it fires to the moment it becomes tamper-evident evidence. The diagram below traces that pipeline: a preservation action emits a structured event, the emitter stamps it with a trusted timestamp and a signature, and it is appended to a hash-chained ledger where each entry cryptographically seals the one before it.
The emission-to-ledger pipeline: an action becomes a typed event, is timestamped and signed, and is sealed into a hash-chained ledger that a scheduler later re-reads to trigger fresh integrity checks.
Core Implementation: A Production PREMIS Event Emitter
The emitter is the single entry point every preservation action calls. Modelling the event as a pydantic structure gives validation for free — a malformed event raises before it can reach the ledger — and constructing it in one place guarantees that attribution, timestamp, and outcome are never forgotten. The implementation below defines a strict PREMISEvent model and an emit() function that stamps the event with a UTC timestamp, serialises it as structured JSON, and appends it to an append-only sink. The sink here is a local append-only file opened in "a" mode; in production it is fronted by object-lock storage or a write-once table, but the contract — construct, validate, timestamp, append, never mutate — is identical. Designing that durable sink is the subject of the guide on designing an immutable audit ledger.
"""
premis_emitter.py
A production PREMIS preservation-event emitter with an append-only sink.
Requires: Python 3.9+, pydantic v2
"""
from __future__ import annotations
import hashlib
import json
import logging
import uuid
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
)
logger = logging.getLogger("oais_premis_emitter")
class EventType(str, Enum):
"""Controlled PREMIS event vocabulary emitted by this archive."""
CAPTURE = "capture"
MESSAGE_DIGEST_CALCULATION = "messageDigestCalculation"
VALIDATION = "validation"
INGESTION = "ingestion"
REPLICATION = "replication"
FIXITY_CHECK = "fixityCheck"
MIGRATION = "migration"
DELETION = "deletion"
class LinkedAgent(BaseModel):
"""The Agent an Event is attributed to."""
value: str
agent_type: str = Field(pattern="^(organization|software|person)$")
class PREMISEvent(BaseModel):
"""A single, fully attributed PREMIS preservation event."""
event_identifier: str = Field(default_factory=lambda: str(uuid.uuid4()))
event_type: EventType
event_date_time: str = Field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
linking_object_id: str
linking_agent: LinkedAgent
event_outcome: str = Field(pattern="^(success|failure)$")
event_detail: str
@field_validator("event_date_time")
@classmethod
def _must_be_utc_aware(cls, value: str) -> str:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError("eventDateTime must carry a UTC offset")
return value
def canonical_bytes(self) -> bytes:
"""Deterministic serialisation used for hashing and signing."""
return json.dumps(self.model_dump(mode="json"), sort_keys=True).encode("utf-8")
class AppendOnlyLedger:
"""A write-once sink: entries are appended and hash-chained, never edited."""
def __init__(self, path: str) -> None:
self._path = Path(path)
self._path.touch(exist_ok=True)
def _last_hash(self) -> str:
previous = "0" * 64
with self._path.open("r", encoding="utf-8") as handle:
for line in handle:
if line.strip():
previous = json.loads(line)["entry_hash"]
return previous
def append(self, event: PREMISEvent) -> str:
"""Seal an event to the ledger, chaining it to the prior entry's hash."""
previous_hash = self._last_hash()
chained = hashlib.sha256(previous_hash.encode("utf-8") + event.canonical_bytes())
entry_hash = chained.hexdigest()
record = {
"previous_hash": previous_hash,
"event": event.model_dump(mode="json"),
"entry_hash": entry_hash,
}
with self._path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
return entry_hash
def emit(event: PREMISEvent, ledger: AppendOnlyLedger) -> str:
"""Validate, seal, and log one preservation event. Returns its entry hash."""
entry_hash = ledger.append(event)
logger.info(
"Emitted PREMIS event",
extra={
"event_identifier": event.event_identifier,
"event_type": event.event_type.value,
"object": event.linking_object_id,
"agent": event.linking_agent.value,
"outcome": event.event_outcome,
"entry_hash": entry_hash[:12],
},
)
return entry_hash
if __name__ == "__main__":
ledger = AppendOnlyLedger("/srv/preservation/audit-ledger.jsonl")
fixity_event = PREMISEvent(
event_type=EventType.FIXITY_CHECK,
linking_object_id="ark:/12148/btv1b8449691v",
linking_agent=LinkedAgent(value="fixity-worker-3", agent_type="software"),
event_outcome="success",
event_detail="SHA-256 recomputed 9f2c… matched stored digest 9f2c…",
)
emit(fixity_event, ledger)
Two design choices are deliberate. The event is validated by pydantic before it is ever offered to the ledger, so a missing agent or a naive timestamp fails at construction rather than corrupting the record. And the ledger chains each entry to the hash of the previous one: because entry_hash folds in previous_hash, editing any historical entry changes its hash and breaks every hash after it, which a later verification pass detects immediately. The precise emission patterns for each action type — capture, migration, deletion — are worked through in the guide on emitting PREMIS events for preservation actions.
Integration Points
This layer is a write target for the whole architecture, so its value lies in the interfaces it exposes at each edge. Three child guides cover the disciplines in depth. Emitting PREMIS events for preservation actions details the per-action emission contract — which eventType and which outcome fields each preservation action must produce, and how to make emission an atomic side effect of the action rather than a best-effort afterthought. Designing an immutable audit ledger covers the durable sink itself: object-lock storage, hash chaining, and independent verifiability so no operator — however privileged — can rewrite history. Scheduling fixity re-validation jobs covers the cadence that turns a stored digest into a recurring proof of integrity, distributing re-hash work across worker pools without overwhelming cold storage.
The events themselves are shaped by PREMIS Metadata Mapping, which defines the Object, Event, Agent, and Rights contract this layer serialises; logging is the runtime write path for the model that mapping specifies at rest. Downstream, Long-Term Storage Architecture is both a producer and a consumer: every replication and tier migration it performs emits a replication or migration event here, and it reads back the fixity events this layer records to confirm that geographically distributed copies still agree. And because the ledger is itself sensitive evidence, it runs under Digital Preservation Security Policies: role-based access to the emitter, signing keys held under key management, and access events themselves logged as auditable actions.
Validation and Compliance Rules
Under ISO 16363, a trusted repository must demonstrate that every preservation action is traceable (clause 4.1.8) and that it records the results of every integrity check it performs (clause 4.2.6). Preservation action logging is the concrete evidence those clauses probe, and it must satisfy four hard obligations before an auditor will accept it.
- Signed. Every event carries a cryptographic signature over its canonical bytes, so its authenticity and integrity can be checked independently of the system that stored it. An unsigned event is a claim; a signed event is evidence.
- Timestamped. Every
eventDateTimeis recorded in UTC with an explicit offset from a trusted clock source. A naive local timestamp cannot be ordered reliably against events from another node and is rejected at emission. - Independently verifiable. The ledger’s hash chain lets a third party re-derive every entry hash from the raw events and confirm the chain is unbroken, without trusting the archive’s own tooling. Verification depends only on the stored data and a public hash function.
- Append-only. There is no update or delete path in the write API. A correction is a new event that references the superseded one; a removal leaves a
deletiontombstone. History grows by accretion and is never rewritten — the property every downstream audit assumes.
A record that satisfies all four can survive re-examination years later: an auditor re-hashes the stored bitstream, finds the matching fixityCheck event, verifies its signature, and confirms its position in an unbroken chain. Anything less turns provenance from evidence back into assertion.
Troubleshooting Reference
| Error condition | Root cause | Remediation |
|---|---|---|
| Verification reports a broken hash chain at entry n | A historical entry was edited or a row was deleted from the ledger table | Treat as a tamper event; restore the ledger from a signed replica and open an incident — never “repair” the chain in place |
| Emitted event rejected with a naive-timestamp error | datetime.now() used without timezone.utc inside a custom emitter path |
Always construct eventDateTime with datetime.now(timezone.utc).isoformat(); the model validator rejects offset-free values |
Orphaned Event with no resolvable Agent |
Action fired without passing a linking_agent, or agent identifier not registered |
Fail closed at emission; require a LinkedAgent on every event and validate the identifier against the agent registry before append |
Duplicate fixityCheck events for one object in a single window |
Overlapping scheduled jobs re-queued the same object after a worker timeout | Make jobs idempotent per object + window key; skip if an event for that tuple already exists in the ledger |
deletion left no trace of the removed object |
Emitter deleted the bitstream but no tombstone event was appended | Emit the deletion event before removing the bitstream; retain the former object identifier and authorising agent forever |
| Fixity re-validation falls behind, oldest objects unchecked for months | Scheduler dispatches by insertion order and never prioritises staleness | Schedule by oldest-verified-first; track last fixityCheck per object and drive the queue from that watermark |
Frequently Asked Questions
How is preservation action logging different from PREMIS metadata mapping?
Mapping defines the shape of a provenance record — the Object, Event, Agent, and Rights entities and the constraints they must satisfy. Logging is the runtime write path that fills that shape as actions happen: it captures the transient facts a preservation action produces at the moment of execution, attributes and timestamps them, and seals them into durable storage. Mapping is the schema at rest; logging is the act of committing events to it, correctly and irreversibly, while the archive runs.
Why must the ledger be append-only instead of a normal audit table?
Because the entire audit model assumes history is never rewritten. A conventional table with UPDATE and DELETE lets a privileged operator — or a compromised account — quietly alter what the record says happened, which is precisely the failure an ISO 16363 audit is designed to detect. An append-only, hash-chained ledger makes any edit to a past entry break every subsequent hash, so tampering is not prevented by trust but exposed by mathematics. Corrections and removals are expressed as new events, so the trail only ever grows.
What does it mean for an event to be independently verifiable?
It means a third party can confirm the event is authentic and unaltered using only the stored data and public algorithms, without trusting the archive’s own software. Each event is signed over its canonical bytes and chained by hash to its predecessor, so a verifier re-derives every entry hash from the raw records, checks the chain is unbroken, and validates each signature. If all three hold, the events are proven to be the ones originally emitted, in the order emitted — evidence that stands on its own.
How often should fixity be re-validated, and why schedule it at all?
Fixity computed once at ingest only proves integrity at ingest; bit rot, silent storage faults, and tampering all happen later. Re-validation on a recurring cadence — driven oldest-verified-first so no object drifts unchecked — turns a stored digest into a continuously renewed proof of integrity, and each run emits a fixityCheck event that becomes part of the permanent record. The interval is a trade-off between detection latency and the read cost of re-hashing cold storage, which is why the work is scheduled and rate-limited rather than run all at once.
Related
- OAIS-Compliant Digital Preservation Architecture — the parent area this logging layer serves as its provenance backbone.
- Emitting PREMIS events for preservation actions — the per-action emission contract for capture, migration, deletion, and more.
- Designing an immutable audit ledger — object-lock storage and hash chaining for a tamper-evident, independently verifiable record.
- Scheduling fixity re-validation jobs — the recurring cadence that turns a stored digest into a continuous proof of integrity.
- PREMIS Metadata Mapping — the Object, Event, Agent, and Rights contract this layer writes into at runtime.
- Digital Preservation Security Policies — the access controls, signing keys, and immutable-audit rules the ledger runs under.