eCTD Sequence Assembly & Lifecycle Management
Assembling an eCTD sequence is the step where a folder of approved documents becomes a structured, machine-validated submission unit. The task this automates is deceptively mechanical: place each leaf file into the correct Common Technical Document module, give it the right lifecycle operation relative to what the application already contains, compute its checksum, and write the XML backbone that a health authority’s software reads. The compliance stakes are high because the backbone — not the documents — is what a gateway validates first, and a single wrong operator or a mismatched checksum produces a technical rejection that stops the filing clock. This work is the assembly stage of the submission pipeline, and it consumes the artifacts produced by FDA/EMA Submission Schema Design.
What the assembler decides
Every leaf entering a sequence forces three decisions: which module it belongs to, what lifecycle operation it performs against the application’s history, and whether its content has changed since a prior sequence referenced it. Encoding those decisions as data — not as branching code scattered across the assembler — is what lets a regulatory-affairs reviewer audit the logic without reading Python.
Library and tooling landscape
There is no maintained open-source library that produces a complete, submission-ready eCTD backbone, which is why teams either license a commercial publishing tool or build a controlled generator around a solid XML library. The table below is the honest landscape for a Python-first team.
| Option | Role | Clinical-grade verdict |
|---|---|---|
lxml |
XML backbone generation and XSD validation | Recommended — fast, standards-complete, validates against the DTD/schema |
xml.etree.ElementTree |
Stdlib XML | Usable but lacks convenient DTD validation; prefer lxml |
hashlib |
Leaf checksums (MD5 per eCTD spec, plus SHA-256 for the audit trail) | Recommended — stdlib, deterministic |
| Commercial publishers (docuBridge, EXTEDO, GlobalSubmit) | Full lifecycle publishing suites | Appropriate for large sponsors; still benefit from a pre-flight validator in code |
PyPDF2 |
(legacy) PDF touch-ups during assembly | Deprecated — unmaintained; use pypdf instead (see callout) |
Deprecated dependency:
PyPDF2is end-of-life and its releases now simply re-exportpypdf. Any assembly step that stamps or repaginates a leaf PDF should depend on the maintainedpypdfpackage directly. Pin it inpyproject.tomlso a submission can be reconstructed from a known toolchain.
Step-by-step implementation
The assembler is a small, testable pipeline. Each stage is pure where it can be and emits an audit event where it cannot.
1. Model the leaf and its operation
A frozen dataclass makes the leaf’s identity and operation explicit and prevents an assembly step from silently mutating it.
"""Typed model of an eCTD leaf and its lifecycle operation."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class Operation(str, Enum):
NEW = "new"
REPLACE = "replace"
APPEND = "append"
DELETE = "delete"
@dataclass(frozen=True)
class Leaf:
"""One file placed into a Common Technical Document module."""
leaf_id: str
module: str # e.g. "m3.2.p.1"
path: Path
operation: Operation
modified_leaf_id: str | None = None # required for replace/append/delete
def md5(self) -> str:
"""eCTD checksum of the leaf content (spec mandates MD5)."""
return hashlib.md5(self.path.read_bytes()).hexdigest()
def sha256(self) -> str:
"""Provenance hash carried into the audit trail."""
return hashlib.sha256(self.path.read_bytes()).hexdigest()
2. Enforce the lifecycle contract
A replace, append, or delete is illegal without a target leaf to operate on. Rejecting that at assembly time is what prevents an orphaned-leaf rejection at the gateway.
"""Validate that a leaf's operation is internally consistent."""
from __future__ import annotations
def validate_operation(leaf: "Leaf") -> None:
needs_target = leaf.operation in {
Operation.REPLACE,
Operation.APPEND,
Operation.DELETE,
}
if needs_target and not leaf.modified_leaf_id:
raise ValueError(
f"{leaf.operation.value} leaf {leaf.leaf_id!r} must reference a prior leaf"
)
if leaf.operation is Operation.NEW and leaf.modified_leaf_id:
raise ValueError(
f"new leaf {leaf.leaf_id!r} must not reference a prior leaf"
)
3. Number the sequence and write the backbone
eCTD sequences are four-digit, zero-padded, and strictly increasing within an application. The backbone references each leaf by relative path, checksum, and operation.
"""Assemble leaves into a numbered eCTD sequence backbone."""
from __future__ import annotations
import os
from lxml import etree
def build_backbone(application_id: str, sequence_number: int, leaves: list["Leaf"]) -> bytes:
root = etree.Element("ectd", nsmap={"xlink": "http://www.w3.org/1999/xlink"})
root.set("application", application_id)
root.set("sequence", f"{sequence_number:04d}")
for leaf in leaves:
validate_operation(leaf)
node = etree.SubElement(root, "leaf")
node.set("ID", leaf.leaf_id)
node.set("operation", leaf.operation.value)
node.set("module", leaf.module)
node.set("checksum", leaf.md5())
node.set("checksum-type", "md5")
if leaf.modified_leaf_id:
node.set("modified-file", leaf.modified_leaf_id)
return etree.tostring(root, xml_declaration=True, encoding="UTF-8", pretty_print=True)
def next_sequence_number(existing: list[int]) -> int:
"""Sequences must be gapless and increasing; the first is 0000."""
if not existing:
return 0
return max(existing) + 1
Validation and audit-trail integration
Assembly is not complete until the sequence is checked against the authority’s structure rules and the act of assembling is recorded. Output flows into two places: the pre-submission technical validation that runs before transport, and the append-only audit log that records who assembled which sequence from which artifacts. Because each leaf carries the same SHA-256 produced at ingestion, the audit event ties a submitted leaf back through schema validation to the original document.
"""Emit an audit event when a sequence is assembled."""
from __future__ import annotations
import json
import logging
logger = logging.getLogger("ectd.assembly")
def record_assembly(application_id: str, sequence_number: int, leaves: list["Leaf"]) -> None:
event = {
"action": "sequence_assembled",
"application_id": application_id,
"sequence": f"{sequence_number:04d}",
"leaves": [
{"leaf_id": leaf.leaf_id, "operation": leaf.operation.value, "sha256": leaf.sha256()}
for leaf in leaves
],
}
logger.info(json.dumps(event, sort_keys=True))
Error categorization and recovery
Assembly failures fall into three classes, each with a distinct recovery:
- Structural (permanent). A leaf targets a module that does not exist, or a
replacereferences a missing prior leaf. These are defects in the input; fail fast, surface the specific leaf, and do not retry — retrying cannot fix a malformed reference. - Content (permanent). A checksum mismatch between the leaf on disk and the one recorded means the file changed underneath the assembler. Re-hash, and if the change was unintended, reject the sequence rather than shipping an unexpected file.
- Transient. A read failure on a network-mounted document store is retryable with bounded backoff, exactly as covered in Fallback Routing for Portal Outages.
Compliance checklist
Before a sequence advances to signing and transport, confirm each attribute holds:
- Every leaf resolves to a valid Common Technical Document module
- Each lifecycle operation references a real prior leaf where required
- The sequence number is gapless and strictly increasing for the application
- Every leaf carries an MD5 checksum matching its content on disk
- A SHA-256 provenance hash ties each leaf to its ingested original
- An audit event records who assembled the sequence and from which artifacts
- The assembled backbone validates against the eCTD schema before transport