Promoting a Validated SIP to an AIP, Step by Step
Promotion is the moment a Submission Information Package stops being a candidate and becomes the archival record of last resort, and it is precisely where most pipelines quietly cut corners. This walkthrough belongs to the End-to-End SIP-to-AIP Promotion stage, and it gives the exact ordered sequence for turning a validated SIP into a sealed Archival Information Package without leaving a half-written package, an out-of-order event stream, or a “sealed” flag on bytes nobody has read back. The failure this page prevents is the promotion that looks finished — a row in a database says sealed — while the AIP on storage was never re-verified, its PREMIS chain skips the ingestion event, or a crash between “write” and “commit” left a torso of an object that later fixity sweeps will flag as corruption that never actually happened.
The governing discipline is the same four beats at every step: record intent, act, verify, then commit. Nothing downstream is allowed to observe a state the pipeline has not yet proven. An AIP is sealed last, only after every byte is on archival storage, re-read, and matched against a manifest, and only after the ordered event chain that an auditor will reconstruct has been emitted in the order the actions actually happened.
Where Promotion Goes Wrong
Four concrete anti-patterns account for nearly every corrupt or unauditable AIP produced at this stage:
- Sealing before all checks pass. The pipeline treats “SIP validation succeeded earlier” as permission to seal now. But validation and promotion are separated in time; a bit could have flipped in staging storage, or a file could have been swapped by a rerun. Promotion must re-derive fixity from the bytes it is about to archive, not trust an upstream result. This is the same re-check the batch validation schemas enforce at ingest, applied again at the storage boundary.
- Non-atomic seal. The AIP is written file-by-file into its final archival location, and the “sealed” marker is set partway through — or a crash leaves objects half-copied with no marker at all. A reader or a fixity sweep then encounters an AIP that is neither absent nor complete. Atomicity means the AIP becomes visible as one indivisible commit, never as a growing pile.
- Events emitted out of order (or not at all). PREMIS events are the provenance spine. If
ingestionis recorded before the AIP is actually on storage, orreplicationis emitted before ingestion, the chain an auditor reconstructs describes a history that never happened. Events must be emitted after the action they describe verifiably completed, in causal order. - No verify-on-read. The pipeline computes a digest as it writes, then trusts the write. A silent storage fault — a truncated object, a dropped write on a full volume — is invisible until years later. Promotion must read the archived bytes back and re-hash them before it will seal.
The state diagram below shows the whole promotion as an ordered sequence of gated transitions. Every stage either advances to the next gate or diverts to a fail-closed terminal state; nothing skips ahead, and SEALED is reachable only through the verify-on-read gate.
Promotion as an ordered, gated sequence: each step must pass before the next begins, every gate can divert to one fail-closed terminal, and SEALED is committed last — only after the archived bytes are re-read and verified.
Step-by-Step Promotion
The steps below follow the diagram in order. Each corresponds to a helper that raises on failure; the orchestrating promote_sip_to_aip() at the end wires them into a single fail-closed transaction. The scaffolding types and logger come first.
from __future__ import annotations
import hashlib
import json
import logging
import os
import shutil
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger("archival.promotion")
READ_CHUNK = 1 << 20 # 1 MiB streaming reads keep large masters off the heap
class PromotionError(RuntimeError):
"""Raised on any failed promotion gate; the pipeline fails closed."""
@dataclass(frozen=True)
class SipFile:
"""One file inside the SIP, with the fixity recorded at validation time."""
relpath: str
source_path: Path
expected_sha256: str
@dataclass
class PromotionContext:
"""Carries state across the ordered promotion steps."""
sip_id: str
aip_id: str
files: list[SipFile]
archival_root: Path
formats: dict[str, str] = field(default_factory=dict) # relpath -> PRONOM PUID
manifest_digest: str = ""
Step 1 — Confirm SIP validation and re-verify fixity
Do not trust the upstream “valid” flag. Re-derive the SHA-256 of every file from the bytes on disk and compare it to the digest recorded at validation. A mismatch here means the SIP changed between validation and promotion, and the only safe action is to fail closed before anything is written to archival storage.
def sha256_of(path: Path) -> str:
"""Stream a file through SHA-256 without loading it whole into memory."""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(READ_CHUNK), b""):
digest.update(chunk)
return digest.hexdigest()
def reverify_fixity(ctx: PromotionContext) -> None:
"""STEP 1 — re-derive fixity from the bytes we are about to archive."""
for entry in ctx.files:
actual = sha256_of(entry.source_path)
if actual != entry.expected_sha256:
logger.error(
"fixity_mismatch",
extra={"sip_id": ctx.sip_id, "file": entry.relpath,
"expected": entry.expected_sha256, "actual": actual},
)
raise PromotionError(f"Fixity mismatch on {entry.relpath}")
logger.info("fixity_reverified",
extra={"sip_id": ctx.sip_id, "file_count": len(ctx.files)})
$$ \text{promote} ;\Longleftarrow; \bigwedge_{f \in \text{SIP}} \big(\operatorname{SHA256}(f_{\text{bytes}}) = h_f^{\text{recorded}}\big) $$
Promotion is permitted only when the conjunction holds for every file — one mismatched digest vetoes the whole package.
Step 2 — Format-identify every file
Every file gets a definitive PRONOM identification (a PUID), not a guess from its extension. This is the discipline covered in depth by the OAIS reference model implementation: an AIP without representation information is not preservable. In production this call shells out to Siegfried or DROID; here it is a typed seam you can back with either.
def identify_formats(ctx: PromotionContext) -> None:
"""STEP 2 — resolve a PRONOM PUID for every file; unknowns fail closed."""
for entry in ctx.files:
puid = resolve_puid(entry.source_path)
if not puid:
logger.error("format_unidentified",
extra={"sip_id": ctx.sip_id, "file": entry.relpath})
raise PromotionError(f"No PRONOM identification for {entry.relpath}")
ctx.formats[entry.relpath] = puid
logger.info("formats_identified",
extra={"sip_id": ctx.sip_id, "distinct_puids": len(set(ctx.formats.values()))})
def resolve_puid(path: Path) -> str:
"""Return a PRONOM PUID for a file (seam over Siegfried/DROID)."""
# A real implementation invokes `sf -json` or the DROID API and parses the PUID.
suffix_map = {".tif": "fmt/353", ".tiff": "fmt/353",
".jp2": "x-fmt/392", ".pdf": "fmt/477"}
return suffix_map.get(path.suffix.lower(), "")
Step 3 — Assemble metadata (PREMIS / METS)
With formats known, build the descriptive and preservation metadata that will travel inside the AIP. The PREMIS objects record fixity and format; the METS document is the structural map. Crucially, this step only assembles metadata — it does not yet emit provenance events, because the actions those events describe have not happened.
def assemble_metadata(ctx: PromotionContext) -> dict:
"""STEP 3 — build in-package PREMIS objects and a METS structural map."""
premis_objects = [
{
"objectIdentifier": {"type": "AIP-relative", "value": e.relpath},
"objectCharacteristics": {
"fixity": {"messageDigestAlgorithm": "SHA-256",
"messageDigest": e.expected_sha256},
"format": {"formatRegistry": "PRONOM",
"formatRegistryKey": ctx.formats[e.relpath]},
},
}
for e in ctx.files
]
mets = {
"aip_id": ctx.aip_id,
"created": datetime.now(timezone.utc).isoformat(),
"fileSec": [{"path": e.relpath, "puid": ctx.formats[e.relpath]} for e in ctx.files],
}
logger.info("metadata_assembled",
extra={"sip_id": ctx.sip_id, "premis_object_count": len(premis_objects)})
return {"premis": premis_objects, "mets": mets}
Step 4 — Compute the AIP-level manifest and digests
Individual file fixity is not enough; the AIP needs one manifest that binds every path to its digest, plus a digest of the manifest itself. That top-level digest is the single value a future fixity sweep checks to prove the whole package is intact. The manifest is written into the package in Step 5 and re-verified in Step 6.
def compute_manifest(ctx: PromotionContext, metadata: dict) -> dict:
"""STEP 4 — build the AIP manifest and a digest that seals the whole package."""
entries = {e.relpath: e.expected_sha256 for e in ctx.files}
manifest = {
"aip_id": ctx.aip_id,
"algorithm": "SHA-256",
"files": dict(sorted(entries.items())), # sorted → deterministic serialization
"metadata_present": sorted(metadata.keys()),
}
serialized = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
ctx.manifest_digest = hashlib.sha256(serialized).hexdigest()
logger.info("manifest_computed",
extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id,
"manifest_digest": ctx.manifest_digest})
return {"manifest": manifest, "serialized": serialized}
Step 5 — Write to archival storage atomically
Never build the AIP in place at its final path. Stage the whole package in a temporary directory on the same filesystem as the archival root, then make it visible with a single atomic os.replace. If the process dies mid-copy, the final location still does not exist — there is no torso to clean up, and no reader ever sees a partial AIP.
def write_atomically(ctx: PromotionContext, metadata: dict, manifest: dict) -> Path:
"""STEP 5 — stage the AIP, then publish it with one atomic rename."""
final_path = ctx.archival_root / ctx.aip_id
if final_path.exists():
raise PromotionError(f"AIP {ctx.aip_id} already exists; refusing to overwrite")
staging = Path(tempfile.mkdtemp(dir=ctx.archival_root, prefix=f".stage-{ctx.aip_id}-"))
try:
(staging / "data").mkdir()
for entry in ctx.files:
dest = staging / "data" / entry.relpath
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(entry.source_path, dest)
(staging / "manifest.json").write_bytes(manifest["serialized"])
(staging / "premis.json").write_text(json.dumps(metadata["premis"], indent=2))
(staging / "mets.json").write_text(json.dumps(metadata["mets"], indent=2))
# fsync directory metadata so the rename survives a power loss.
dir_fd = os.open(staging, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
os.replace(staging, final_path) # atomic on a POSIX filesystem
except Exception:
shutil.rmtree(staging, ignore_errors=True)
raise
logger.info("aip_written", extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id,
"path": str(final_path)})
return final_path
Step 6 — Verify on read
Now read the archived bytes back from their final location and re-hash them. This is the gate that catches a truncated copy or a silent storage fault. Only if the re-read manifest digest matches the value computed in Step 4 does promotion proceed toward sealing.
def verify_on_read(ctx: PromotionContext, aip_path: Path) -> None:
"""STEP 6 — re-read archived bytes and confirm nothing changed in transit."""
for entry in ctx.files:
archived = aip_path / "data" / entry.relpath
actual = sha256_of(archived)
if actual != entry.expected_sha256:
raise PromotionError(f"Read-back mismatch on {entry.relpath}")
manifest_bytes = (aip_path / "manifest.json").read_bytes()
redigest = hashlib.sha256(manifest_bytes).hexdigest()
if redigest != ctx.manifest_digest:
raise PromotionError("Manifest digest changed on read-back")
logger.info("verify_on_read_ok",
extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id})
Step 7 — Emit ingestion, then replication, events
Only now — with verified bytes on storage — are the provenance events true. Emit ingestion first, then replication, in that causal order; each event carries the manifest digest so an auditor can bind the event to the exact package state. The event-emission contract itself is defined by emitting PREMIS events for preservation actions; here we simply respect its ordering.
def emit_event(ctx: PromotionContext, event_type: str, outcome: str) -> None:
"""Append one ordered PREMIS event to the provenance ledger."""
event = {
"eventType": event_type,
"eventDateTime": datetime.now(timezone.utc).isoformat(),
"eventOutcome": outcome,
"linkedObject": ctx.aip_id,
"manifestDigest": ctx.manifest_digest,
}
record_premis_event(event) # durable, append-only sink
logger.info("premis_event_emitted",
extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id,
"event_type": event_type, "outcome": outcome})
def record_premis_event(event: dict) -> None:
"""Persist a PREMIS event to the append-only preservation ledger (seam)."""
# A real sink writes to the immutable audit ledger; kept side-effect-free here.
logger.debug("ledger_append", extra={"event": event})
Step 8 — Mark the AIP sealed
Sealing is a single atomic state transition, and it happens last. The sealed marker means “every prior gate passed and the record is immutable.” Because the events were already emitted and verified, the seal never precedes the evidence for it.
def mark_sealed(ctx: PromotionContext, aip_path: Path) -> None:
"""STEP 8 — commit the seal atomically as the final action."""
marker = aip_path / "SEALED"
tmp = aip_path / ".SEALED.tmp"
tmp.write_text(json.dumps({"aip_id": ctx.aip_id,
"manifest_digest": ctx.manifest_digest,
"sealed_at": datetime.now(timezone.utc).isoformat()}))
os.replace(tmp, marker) # atomic flip to sealed
logger.info("aip_sealed", extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id})
The orchestrator: ordered, verified, fail-closed
promote_sip_to_aip() runs the eight steps in order. Any raised PromotionError aborts before the seal, emits a failure event, and leaves the SIP untouched — the fail-closed guarantee the diagram encodes.
def promote_sip_to_aip(ctx: PromotionContext) -> Path:
"""Promote a validated SIP to a sealed AIP via ordered, verified, atomic steps."""
logger.info("promotion_started", extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id})
aip_path: Path | None = None
try:
reverify_fixity(ctx) # STEP 1
identify_formats(ctx) # STEP 2
metadata = assemble_metadata(ctx) # STEP 3
manifest = compute_manifest(ctx, metadata) # STEP 4
aip_path = write_atomically(ctx, metadata, manifest) # STEP 5
verify_on_read(ctx, aip_path) # STEP 6
emit_event(ctx, "ingestion", "success") # STEP 7 (ordered)
emit_event(ctx, "replication", "success")
mark_sealed(ctx, aip_path) # STEP 8
except PromotionError as exc:
emit_event(ctx, "ingestion", f"failure: {exc}")
if aip_path and aip_path.exists() and not (aip_path / "SEALED").exists():
shutil.rmtree(aip_path, ignore_errors=True) # remove the unsealed torso
logger.error("promotion_failed",
extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id, "error": str(exc)})
raise
logger.info("promotion_completed", extra={"sip_id": ctx.sip_id, "aip_id": ctx.aip_id})
return aip_path
Validation and Verification
A promotion that returns without raising is not proof on its own; confirm the seal with independent checks rather than trusting the return value:
- Re-derive the top-level digest from storage. Read
manifest.jsonback from the sealed AIP and hash it; it must equal themanifestDigestrecorded on theingestionandreplicationevents. If they diverge, the seal is describing bytes that changed. - Assert event order and completeness. Query the provenance ledger for the AIP and confirm exactly one
ingestionevent precedes exactly onereplicationevent, both withsuccessoutcomes, both stamped before the seal timestamp. A missing or reordered event is a promotion bug even when the bytes are fine. - Confirm the seal is the last write. The
SEALEDmarker’s mtime must be later than every data file and every event. If any object is newer than the seal, something wrote after commit and the AIP is no longer trustworthy. - Prove atomicity by fault injection. Kill the process between Step 5 and Step 8 in a staging environment and confirm the final path either does not exist or contains no
SEALEDmarker — never a half-package that reads as complete.
Edge Cases and Gotchas
| Failure mode | Root cause | Consequence if ignored | Correct handling |
|---|---|---|---|
| Cross-device rename fails | Staging dir on a different filesystem than the archival root | os.replace raises OSError (EXDEV); no atomicity |
Create the staging dir inside archival_root so the rename is same-device |
| Multi-page TIFF partial read | Streaming stops early on a truncated master | Read-back digest passes on a short file | Compare byte length as well as digest; fail on size drift |
| Unidentified proprietary format | Scanner extension not in PRONOM | identify_formats blocks promotion |
Register a local PUID or normalize to TIFF/JP2 before promoting |
| Duplicate AIP id on rerun | A retried batch reuses aip_id |
Silent overwrite of a sealed record | write_atomically refuses when the final path already exists |
| Event emitted before verify | ingestion logged in Step 5 |
Provenance claims success on unverified bytes | Emit events only in Step 7, after verify-on-read |
| Result-store expiry | Events kept only in a volatile backend | Audit trail evaporates | Persist events to the durable append-only ledger, not a cache |
Three archival-specific traps deserve extra care. First, legacy Latin-1 filenames inside older SIPs can round-trip badly through JSON — normalize paths to UTF-8 NFC before they enter the manifest, or two “identical” filenames will produce different digests. Second, a byte-order or byte-offset error in a bespoke scanner container can leave a file whose digest is stable but whose internal structure is corrupt; format identification catches this only if the tool validates structure (JHOVE-style), not just magic bytes. Third, when a gate fails after Step 5, the orchestrator removes the unsealed torso — but never a package that already carries a SEALED marker; recovering from a failure that straddles the seal is the job of handling AIP promotion rollback on fixity failure, which treats a sealed-then-suspect AIP as an immutable record to quarantine rather than delete.
For the canonical semantics of the events emitted here, consult the Library of Congress PREMIS Data Dictionary, and for the PRONOM identifiers used in Step 2, the PRONOM registry. Both are the same references the rest of the pipeline treats as authoritative.
Frequently Asked Questions
Why re-verify fixity during promotion if the SIP was already validated?
Because validation and promotion are separated in time and storage. Between the two, a bit can flip in staging, a rerun can swap a file, or a partial write can truncate a master. Trusting the earlier “valid” flag means sealing whatever is on disk now, which may not be what was validated. Re-deriving SHA-256 from the exact bytes about to be archived closes that gap, and it costs one streaming read per file — cheap insurance against sealing corruption as though it were the record.
What makes the seal atomic, and why does it matter?
The AIP is assembled in a temporary directory on the same filesystem and made visible with a single os.replace, and the SEALED marker is flipped with a second atomic rename as the very last action. Atomicity matters because a crash at any earlier point leaves either no final AIP or an unsealed package the pipeline knows to discard — never a half-written package that a reader or a fixity sweep mistakes for a complete, trustworthy record. Without atomicity, “sealed” would be a lie told partway through a copy.
Why must ingestion and replication events be emitted in that specific order?
PREMIS events are the provenance spine an auditor replays to reconstruct what happened. Replication is only meaningful after ingestion has verifiably placed the AIP on archival storage, so emitting replication first — or emitting either before verify-on-read — records a causal history that never occurred. Emitting ingestion then replication, both after the bytes are read back and matched, keeps the ledger a faithful account rather than an optimistic one.
What happens if a step fails after the AIP is already written?
The orchestrator fails closed: it emits a failure event and removes the unsealed torso from archival storage, leaving the source SIP intact for a clean retry. It never deletes a package that already carries a SEALED marker, because a sealed AIP is an immutable record. A failure discovered after sealing is a rollback-and-quarantine problem, handled separately, not something the promotion path is allowed to silently overwrite.
Related
- End-to-End SIP-to-AIP Promotion — the parent stage: the full promotion pipeline this ordered walkthrough sits inside.
- Handling AIP Promotion Rollback on Fixity Failure — the companion path for when a gate fails or a sealed AIP is later found suspect.
- Emitting PREMIS Events for Preservation Actions — the event contract and ordering rules the promotion sequence depends on.