Validating eCTD Sequences Against Technical Rejection Criteria
A health authority’s gateway runs its own structural checks on an incoming eCTD sequence, and if the sequence fails one, the rejection arrives asynchronously — sometimes a day or more later, after the submission clock has already started. The only way to make that clock a schedule instead of a gamble is to run the same class of checks locally, in Python, before the sequence is transported. This guide builds that pre-flight validator: a set of rules grouped by rejection severity, applied to an assembled sequence produced by eCTD Sequence Assembly & Lifecycle Management, and run immediately before the signing and transport stages of the Electronic Submission Gateway & E-Signature Automation pipeline.
Authorities publish these criteria as tables of numbered business rules, each carrying a severity that determines whether it produces an outright rejection, a deficiency letter, or simply a note for a future filing. The value of encoding them in Python is not just speed — a rule engine that runs in seconds rather than a review cycle that takes days — it is that the same rules become a regression suite. A defect a sponsor hit once, in a prior sequence, becomes a rule the validator checks on every subsequent one, so institutional knowledge about what a gateway rejects stops living in one reviewer’s memory and starts living in code that runs on every submission.
Why naive approaches fail
Treating validation as a single pass/fail check, or running it only after transport, produces predictable failures:
- One severity level for everything. Authorities publish rejection criteria at multiple severities — a missing checksum is usually fatal, a non-standard PDF bookmark structure is often just a warning. A validator that raises the same exception for both either blocks submissions unnecessarily or lets real defects through disguised as warnings.
- Validating after transport. Running structural checks as a courtesy after the gateway has already accepted or rejected the file defeats the purpose; the whole point of local validation is to catch a defect at the transformation boundary, before the filing clock starts.
- Trusting the assembler’s own checksum. Assembly computes an MD5 for each leaf when it writes the backbone. Validation has to independently recompute that checksum from the file on disk and compare, not simply trust the value the assembler already wrote — otherwise a file corrupted or swapped after assembly goes undetected.
- Ignoring cross-sequence state. Some of the highest-value checks — does a
replacereference a leaf that actually exists in a prior sequence — require the application’s full lifecycle history, not just the current sequence in isolation. A validator that only looks at the current sequence cannot catch a dangling reference.
Architecture overview
The validator is a short pipeline: load the sequence and its lifecycle history, run every rule, and categorize the results by severity before deciding whether to allow transport.
Setup and configuration
The validator needs nothing beyond the standard library and lxml for reading the backbone; severity policy — whether a medium-severity finding blocks transport in a given environment — is read from the environment so a stricter sandbox policy can be enforced without a code change.
pip install "lxml>=5.2"
"""Environment-driven severity policy for technical validation."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class ValidationPolicy:
block_on_medium: bool
@classmethod
def from_env(cls) -> "ValidationPolicy":
raw = os.environ.get("ECTD_BLOCK_ON_MEDIUM", "true").lower()
return cls(block_on_medium=raw in {"1", "true", "yes"})
Full working implementation
Findings are modeled as a severity enum plus a structured result; the validator is a list of independent rule functions so a new criterion can be added without touching the others.
"""Validate an assembled eCTD sequence against technical rejection criteria."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
class Severity(str, Enum):
HIGH = "high" # authority rejects the sequence outright
MEDIUM = "medium" # authority may accept with a deficiency letter
LOW = "low" # advisory; does not block acceptance
@dataclass(frozen=True)
class Finding:
rule: str
severity: Severity
leaf_id: str | None
message: str
@dataclass(frozen=True)
class LeafRecord:
leaf_id: str
module: str
path: Path
declared_md5: str
operation: str # new | replace | append | delete
modified_leaf_id: str | None = None
VALID_MODULES = {"m1", "m2", "m3", "m4", "m5"}
@dataclass
class Validator:
"""Runs every rule against a sequence and its lifecycle history."""
prior_leaf_ids: set[str] = field(default_factory=set)
findings: list[Finding] = field(default_factory=list)
def validate(self, leaves: list[LeafRecord]) -> list[Finding]:
self.findings = []
seen_ids: set[str] = set()
for leaf in leaves:
self._check_module_placement(leaf)
self._check_checksum(leaf)
self._check_pdf_constraints(leaf)
self._check_lifecycle_reference(leaf)
if leaf.leaf_id in seen_ids:
self.findings.append(
Finding("duplicate_leaf_id", Severity.HIGH, leaf.leaf_id, "leaf ID reused within sequence")
)
seen_ids.add(leaf.leaf_id)
return self.findings
def _check_module_placement(self, leaf: LeafRecord) -> None:
top_module = leaf.module.split(".")[0]
if top_module not in VALID_MODULES:
self.findings.append(
Finding("invalid_module", Severity.HIGH, leaf.leaf_id, f"unknown module {leaf.module!r}")
)
def _check_checksum(self, leaf: LeafRecord) -> None:
if not leaf.path.is_file():
self.findings.append(
Finding("missing_file", Severity.HIGH, leaf.leaf_id, f"leaf file not found: {leaf.path}")
)
return
actual = hashlib.md5(leaf.path.read_bytes()).hexdigest()
if actual != leaf.declared_md5:
self.findings.append(
Finding("checksum_mismatch", Severity.HIGH, leaf.leaf_id, "declared MD5 does not match file content")
)
def _check_pdf_constraints(self, leaf: LeafRecord) -> None:
if leaf.path.suffix.lower() != ".pdf":
return
header = leaf.path.read_bytes()[:8]
if not header.startswith(b"%PDF-1."):
self.findings.append(
Finding("pdf_version_unrecognized", Severity.MEDIUM, leaf.leaf_id, "PDF header missing or non-standard")
)
version = header[5:8]
if version in (b"1.3", b"1.4"):
self.findings.append(
Finding("pdf_version_low", Severity.LOW, leaf.leaf_id, f"PDF version {version.decode()} is dated")
)
def _check_lifecycle_reference(self, leaf: LeafRecord) -> None:
needs_target = leaf.operation in {"replace", "append", "delete"}
if not needs_target:
return
if not leaf.modified_leaf_id:
self.findings.append(
Finding("missing_modified_file", Severity.HIGH, leaf.leaf_id, f"{leaf.operation} requires modified-file")
)
return
if leaf.modified_leaf_id not in self.prior_leaf_ids:
self.findings.append(
Finding(
"dangling_modified_file",
Severity.HIGH,
leaf.leaf_id,
f"modified-file {leaf.modified_leaf_id!r} not found in prior lifecycle",
)
)
def blocks_transport(findings: list[Finding], policy: "ValidationPolicy") -> bool:
for finding in findings:
if finding.severity is Severity.HIGH:
return True
if finding.severity is Severity.MEDIUM and policy.block_on_medium:
return True
return False
Each rule appends independently rather than short-circuiting, so a single validation pass surfaces every defect in a sequence at once instead of forcing a fix-and-rerun cycle for each finding in turn. That matters operationally: a regulatory-affairs coordinator fixing a sequence the night before a filing deadline needs the complete list of defects on the first run, not one new error every time the previous one is patched and the validator is rerun.
blocks_transport is deliberately the only place severity policy is interpreted. Every rule function above only ever appends a Finding with a severity; none of them decide whether the sequence should actually be blocked. Keeping that decision in one function makes the gate auditable on its own — a reviewer can read blocks_transport in isolation and know exactly what stops a submission, without tracing the policy through every individual rule.
Validation and edge-case handling
A few situations need explicit handling beyond the rules above:
- Sequence numbering gaps. A gap in an application’s sequence numbers is itself a high-severity structural defect at many authorities; check it against the full numbering history, not just the current sequence, the same cross-sequence state the lifecycle-reference rule depends on.
- A leaf with no lifecycle history yet. The very first sequence for a new application has an empty
prior_leaf_idsset, so every leaf in it must useoperation == "new"; areplaceorappendin a first sequence is always a defect and the rule above catches it correctly because the set is empty. - Medium-severity findings in a production filing. Some authorities will accept a sequence with medium-severity findings and follow up with a deficiency letter; others will not.
ValidationPolicy.block_on_mediumexists precisely so the same rule engine can be stricter in a pre-production sandbox and match the authority’s actual behavior in production. - Findings for a leaf that failed on file access. Once
_check_checksumrecords amissing_filefinding, downstream rules that also try to read the file (PDF constraints) should not raise a second, less informative exception — the implementation above already guards this by returning early after recording the missing-file finding. - Bookmarks and table-of-contents structure. Some authorities additionally require every module-level PDF to carry a navigable bookmark tree matching its section headings; that check reads the PDF’s outline structure rather than its byte-level version header, and belongs in
_check_pdf_constraintsalongside the version check once a project needs it, following the same severity-taggedFindingshape used throughout this validator.
Testing and verification
Tests should prove both that a genuinely broken sequence is rejected and that a clean one produces no findings, since a validator that never returns an empty result list is as broken as one that never raises.
"""Tests for eCTD technical validation."""
from __future__ import annotations
from pathlib import Path
import pytest
def _leaf(tmp_path: Path, name: str, content: bytes = b"%PDF-1.7 x") -> Path:
path = tmp_path / name
path.write_bytes(content)
return path
def test_checksum_mismatch_is_high_severity(tmp_path: Path) -> None:
path = _leaf(tmp_path, "cover.pdf")
leaf = LeafRecord(
leaf_id="l1", module="m1.2", path=path,
declared_md5="0" * 32, operation="new",
)
validator = Validator()
findings = validator.validate([leaf])
assert any(f.rule == "checksum_mismatch" and f.severity is Severity.HIGH for f in findings)
def test_replace_without_prior_leaf_is_rejected(tmp_path: Path) -> None:
import hashlib
path = _leaf(tmp_path, "protocol.pdf")
leaf = LeafRecord(
leaf_id="l2", module="m5.3.1", path=path,
declared_md5=hashlib.md5(path.read_bytes()).hexdigest(),
operation="replace", modified_leaf_id="does-not-exist",
)
validator = Validator(prior_leaf_ids={"some-other-leaf"})
findings = validator.validate([leaf])
assert any(f.rule == "dangling_modified_file" for f in findings)
def test_clean_sequence_has_no_findings(tmp_path: Path) -> None:
import hashlib
path = _leaf(tmp_path, "toc.pdf")
leaf = LeafRecord(
leaf_id="l3", module="m1.1", path=path,
declared_md5=hashlib.md5(path.read_bytes()).hexdigest(),
operation="new",
)
validator = Validator()
assert validator.validate([leaf]) == []
The first two tests prove high-severity structural defects are caught with the right rule name attached; the third proves a genuinely clean leaf produces an empty finding list rather than a validator that always flags something.