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.

Per-leaf assembly decision flow A top-down flow. A leaf enters and its module is resolved. A decision selects the lifecycle operation: new when the document is not yet filed, replace when it supersedes a prior leaf, append when it supplements one. All paths converge on computing the checksum and writing the leaf into the eCTD backbone, which emits an audit event. Leaf file Resolve CTD module In prior sequence? new fresh leaf replace supersede prior append supplement Checksum + write backbone no yes

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: PyPDF2 is end-of-life and its releases now simply re-export pypdf. Any assembly step that stamps or repaginates a leaf PDF should depend on the maintained pypdf package directly. Pin it in pyproject.toml so 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 replace references 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