Building a SIP-to-AIP Compliance Checklist
A prose checklist pinned to a wiki is not a control. It is a description of a control that a tired operator is trusted to apply by hand, and it rots the moment a scanner firmware changes, a new format enters the collection, or a deadline compresses the review. This page belongs to the End-to-End SIP-to-AIP Promotion stage of the pipeline, and it solves a narrow, expensive problem: the gap between a checklist that reads well during an ISO 16363 audit and a checklist that was actually enforced on every Submission Information Package before it became an Archival Information Package. The fix is to stop writing the checklist as a document and start expressing it as executable checks — each returning a pass/fail plus the evidence that proves it, each bound to a specific ISO 16363 criterion and the PREMIS event that records it, and the whole run recorded inside the AIP so the promotion decision is auditable forever.
Why Prose Checklists Fail an Audit
When an ISO 16363 auditor asks “show me that fixity was verified for this package,” a wiki bullet that says “[ ] verify checksums” is worthless. It records an intention, not an outcome, and it cannot answer the three questions an auditor actually asks. The concrete failure modes are all variations on the same root cause — the checklist is not the thing that runs:
- No per-package evidence. A shared document cannot say which SIP was checked or when. There is no artifact tying the tick-box to package
sip-2026-04891, so the repository cannot demonstrate the criterion was met for that object — only that it is met “in general.” ISO 16363 audits are evidence-driven per package, not aspirational. - Drift between the written checklist and the code. The document says “validate against the current schema profile”; the ingest pipeline actually validates against a profile that was frozen eight months ago. Nothing forces the two to agree, so the checklist slowly describes a process that no longer exists.
- Fail-open by default. A human reading a list skips the box they do not understand, or ticks it because the batch is late. A prose checklist has no mechanism to block — it advises. Promotion proceeds regardless, and the failure is discovered only when the object cannot be retrieved years later.
- No linkage to provenance. The checklist result lives in a different system from the PREMIS event history. An auditor cannot pivot from “checklist said fixity passed” to “here is the
fixityCheckevent with the digest and the agent that produced it,” because the two were never connected.
The remedy is to invert the relationship: the checklist is the code. Each criterion becomes a named function over the package that returns a structured result, the promotion gate refuses to proceed unless every function passes, and the collected results are written into the AIP alongside the objects they vouch for. The diagram below shows the gate this produces.
The checklist is a fail-closed gate: only an all-pass run reaches a sealed AIP; any failing criterion quarantines the SIP and emits a report naming what failed.
Modelling a Check as a Result, Not a Boolean
The unit of the whole design is a check that returns why it passed or failed, not merely that it did. A bare bool cannot be audited; a structured result carries the criterion it satisfies, the evidence, and the identifier of the PREMIS event that will record it. Model that first with a frozen dataclass so results cannot be mutated after a check produces them.
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Mapping
logger = logging.getLogger("archival.promotion.compliance")
@dataclass(frozen=True)
class CheckResult:
"""The auditable outcome of a single compliance check over one package."""
name: str
passed: bool
iso16363: str # e.g. "4.2.6" — the criterion this check evidences
premis_event: str # the PREMIS eventType that records the evidence
evidence: Mapping[str, str]
checked_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
# A check is any callable that inspects a package directory and returns a CheckResult.
Check = Callable[[Path], CheckResult]
Each concrete check is a small, single-responsibility function. It does one measurement, records the evidence it observed, and returns a CheckResult. Below are two representative checks — fixity and format identification — written so a redelivery or a re-run produces the identical result, which is exactly the idempotence property the pipeline already relies on for asynchronous ingestion tasks.
import hashlib
import json
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
def check_fixity(package: Path) -> CheckResult:
"""Every payload file's SHA-256 must match the manifest recorded at ingest."""
manifest = json.loads((package / "manifest-sha256.json").read_text("utf-8"))
mismatches: list[str] = []
for relative, expected in manifest.items():
actual = _sha256(package / relative)
if actual != expected:
mismatches.append(relative)
passed = not mismatches
return CheckResult(
name="fixity_verified",
passed=passed,
iso16363="4.2.6", # repository verifies each AIP for integrity
premis_event="fixityCheck",
evidence={
"algorithm": "SHA-256",
"files_checked": str(len(manifest)),
"mismatches": ",".join(mismatches) or "none",
},
)
def check_format_identified(package: Path) -> CheckResult:
"""Every payload file must carry a resolved PRONOM PUID, never 'unknown'."""
sidecar = json.loads((package / "format-report.json").read_text("utf-8"))
unresolved = [name for name, puid in sidecar.items() if puid in ("", "unknown")]
passed = not unresolved
return CheckResult(
name="format_identified",
passed=passed,
iso16363="4.2.5", # repository has associated representation information
premis_event="format identification",
evidence={
"files_identified": str(len(sidecar) - len(unresolved)),
"unresolved": ",".join(unresolved) or "none",
},
)
The SHA-256 basis for check_fixity is a fixed-length digest over the byte stream, chosen because a single flipped bit changes roughly half the output bits:
$$H(\text{file}) = \text{SHA256}(b_0 \Vert b_1 \Vert \cdots \Vert b_{n-1}), \quad H \in {0,1}^{256}$$
Mapping Every Check to a Criterion and a PREMIS Event
The audit value of the framework comes from the mapping, not the checks in isolation. Each check names the ISO 16363 area it evidences and the PREMIS event that records the outcome, so an auditor can pivot from any criterion straight to the durable provenance record. This is the table the framework enforces in code, and it is the table you hand an auditor.
| Check | ISO 16363 area | Evidencing PREMIS event |
|---|---|---|
fixity_verified |
4.2.6 — integrity verification of each AIP | fixityCheck |
format_identified |
4.2.5 — representation information present | format identification |
schema_valid |
4.1.6 — SIP conforms to submission agreement | validation |
virus_scanned |
4.2.3 — data safe to ingest, no active threats | virusCheck |
metadata_complete |
4.2.4 — descriptive metadata sufficient | validation |
provenance_recorded |
4.2.10 — actions logged as PREMIS events | ingestion |
Two checks intentionally share the validation event type: schema conformance and metadata completeness are distinct criteria evidenced by the same class of provenance event, disambiguated by the event’s detail field. The validation check draws on the same JSON Schema profiles enforced by Batch Validation Schemas upstream, so the promotion gate re-asserts, rather than re-invents, the contract the batch already passed at ingest. Every event the run records is written through the shared Preservation Action Logging facility, which is what makes the checklist result and the provenance history queryable as one ledger.
The same mapping renders as a human-readable checklist for reviewers and audit documentation. Because it is generated from the check registry, it can never drift from what the code enforces:
The Gate: Blocking Promotion Unless Every Check Passes
The runner ties the checks together and enforces the fail-closed rule. It logs each check as it runs, aggregates the results, and refuses to return a promotable verdict unless every check passed. Crucially, an exception inside a check is treated as a failure, not a crash — a check that cannot even evaluate its criterion has, by definition, not proven it, so it fails closed.
@dataclass(frozen=True)
class ComplianceReport:
"""The complete, embeddable outcome of running the checklist over a package."""
package_id: str
results: tuple[CheckResult, ...]
@property
def promotable(self) -> bool:
return all(result.passed for result in self.results)
@property
def failures(self) -> tuple[CheckResult, ...]:
return tuple(result for result in self.results if not result.passed)
class ComplianceChecklist:
"""Runs an ordered registry of named checks and gates promotion fail-closed."""
def __init__(self, checks: list[Check]) -> None:
self._checks = checks
def run(self, package: Path, package_id: str) -> ComplianceReport:
results: list[CheckResult] = []
for check in self._checks:
try:
result = check(package)
except Exception as exc: # a check that cannot evaluate has not passed
result = CheckResult(
name=getattr(check, "__name__", "unknown_check"),
passed=False,
iso16363="unknown",
premis_event="validation",
evidence={"error": type(exc).__name__, "message": str(exc)},
)
logger.error(
"compliance_check_errored",
extra={"package_id": package_id, "check": result.name,
"error": type(exc).__name__},
)
logger.info(
"compliance_check_ran",
extra={"package_id": package_id, "check": result.name,
"passed": result.passed, "iso16363": result.iso16363,
"premis_event": result.premis_event},
)
results.append(result)
return ComplianceReport(package_id=package_id, results=tuple(results))
def promote_or_block(package: Path, package_id: str, checklist: ComplianceChecklist) -> bool:
"""Seal the AIP only when every criterion is met; otherwise quarantine and report."""
report = checklist.run(package, package_id)
result_path = package / "compliance-result.json"
result_path.write_text(
json.dumps(
{"package_id": report.package_id,
"promotable": report.promotable,
"results": [result.__dict__ for result in report.results]},
indent=2, default=dict,
),
encoding="utf-8",
)
if not report.promotable:
failing = [r.name for r in report.failures]
logger.error(
"promotion_blocked",
extra={"package_id": package_id, "failing_checks": failing},
)
return False
logger.info("promotion_cleared", extra={"package_id": package_id})
return True
Wiring it together is a matter of registering the checks in the order an auditor expects to read them. The compliance-result.json the run writes is not a side file — it is sealed into the AIP so the promotion decision travels with the package for its whole lifetime, exactly as the step-by-step promotion walkthrough describes when it assembles the final archival package.
def build_default_checklist() -> ComplianceChecklist:
return ComplianceChecklist(
checks=[check_fixity, check_format_identified], # extend with the full registry
)
Validation and Verification
A gate is only trustworthy if you can prove it blocks. Confirm the framework the same way you would confirm any preservation control — by forcing the negative case and inspecting the evidence, not by reading the config:
- Corrupt a byte, expect a block. Flip one byte of a payload file in a test SIP and run
promote_or_block. Thefixity_verifiedcheck must fail,promotablemust beFalse, and the emitted report must name the exact file — proving the gate fails closed rather than warning and proceeding. - Re-derive the evidence. For a cleared package, re-compute the SHA-256 of each payload from archival storage and confirm it matches the digest recorded in the
fixityCheckevent. The checklist result and the PREMIS event must agree; if they diverge, the ledger, not the check, is the system of record. - Assert the mapping is total. Add a test that every registered check returns a non-
"unknown"iso16363value and a validpremis_event. An unmapped check is invisible to an auditor and must fail CI before it can gate a real package. - Diff the rendered checklist against the registry. Generate the human-readable
- [ ]list from the check registry and assert it matches the documentation. Any drift means the prose has started lying about the code again — the original failure this page exists to prevent.
Edge Cases and Gotchas
- Zero-check runs pass vacuously.
all([])isTrue, so an empty registry promotes everything. Guard the runner with an assertion that at least the mandatory checks are registered, or a misconfigured deployment silently disables the gate. - Checks with side effects break idempotence. A check that writes to the package — normalizing a file, rewriting metadata — makes re-running the checklist produce a different second result. Checks must be pure measurements; move any remediation into a separate, logged preservation action.
- Multi-page TIFFs and manifest granularity. A manifest keyed by logical object rather than by file will not catch a corrupt page inside a bound-volume TIFF. Key the fixity manifest by physical file so
check_fixityverifies every page image, not just the container. - Signature-set staleness. A
virusCheckthat passes against a six-month-old signature database is evidence of nothing. Record the signature version in the check’s evidence and fail the check if the database is older than your policy window. - Legacy encodings in metadata fields. A
metadata_completecheck that assumes UTF-8 will raise on a Latin-1 finding aid and, thanks to the fail-closed exception handler, correctly block promotion — but the report should say “decode error,” not “missing field.” Decode explicitly and record the encoding in the evidence.
Anchoring the checklist in executable checks, each mapped to an ISO 16363 criterion and a PREMIS event and sealed into the AIP, turns an audit from an archaeology project into a query. For the criteria catalogue, consult the PREMIS Data Dictionary, and for the digest basis, the Python hashlib documentation.
Frequently Asked Questions
Why express the checklist as code instead of a controlled document?
Because a document records intention while code records outcome. An ISO 16363 auditor needs per-package evidence that a criterion was met — which file, which digest, which event, at what time — and a prose bullet cannot supply that. Executable checks return structured results tied to a specific package and criterion, they cannot silently drift from what the pipeline actually enforces, and they fail closed so a criterion that was not proven blocks promotion instead of being ticked by a rushed operator.
What does “fail closed” mean for a check that throws an exception?
A check that raises has not evaluated its criterion, so it has not proven compliance. The runner catches the exception, converts it into a failing CheckResult whose evidence records the error type, logs it, and lets it block promotion like any other failure. The alternative — letting the exception propagate — would crash the run and could be mistaken for an infrastructure problem rather than a compliance failure. Treating an un-evaluable check as a failure is the only safe default for irreplaceable material.
How does a check map to both an ISO 16363 criterion and a PREMIS event?
Each CheckResult carries two fields: the iso16363 criterion it evidences and the premis_event type that records the evidence in provenance. The mapping is enforced in the result object itself, so every passing check simultaneously satisfies an audit criterion and produces a durable event an auditor can inspect. Some criteria share an event type — schema validity and metadata completeness are both evidenced by validation events — which is disambiguated by the event’s detail field rather than by inventing new event types.
Where is the checklist result stored, and why inside the AIP?
The run writes a compliance-result.json that is sealed into the AIP alongside the payload it vouches for. Storing it inside the package means the promotion decision — every check, its evidence, and its criterion — travels with the object for its entire preservation lifetime, so an audit years later does not depend on a separate database still existing. The individual PREMIS events are additionally written to the shared audit ledger, giving you both a package-local record and a repository-wide queryable history.
Related
- End-to-End SIP-to-AIP Promotion — the parent stage: how the compliance gate fits between a validated SIP and a sealed AIP.
- Promoting a Validated SIP to AIP Step by Step — the promotion procedure that runs this checklist and seals the result into the AIP.
- Preservation Action Logging — the PREMIS event ledger every check writes its evidence to, making the checklist result auditable repository-wide.