eCTD vs NeeS vs VNeeS: Submission Format Selection

Before a single leaf file is placed into a module, a submission pipeline has to answer a question that looks trivial and is not: which electronic format does this filing use? Get it wrong and the packaging step downstream produces a structure the receiving authority’s gateway does not recognize — a rejection that has nothing to do with the science in the dossier. This guide covers the three formats a Python automation builder actually encounters — eCTD, NeeS, and VNeeS — and a decision helper that picks the correct one from jurisdiction, product type, and procedure metadata instead of a hardcoded assumption. It sits under FDA/EMA Submission Schema Design, part of Core Architecture & Regulatory Mapping for Clinical Trials, and the format it selects is what the submission gateway assembles and transports.

Why naive approaches fail

Most pipelines get built against a single reference market and then break the first time a second region or a veterinary product enters the picture:

  • Assuming eCTD everywhere. eCTD is the dominant format for human drug applications to the FDA and for EMA centralized procedures, but treating it as universal ignores that some national and decentralized procedures outside the largest ICH-aligned markets still expect a different packaging convention, and that veterinary products follow an entirely separate track.
  • Ignoring the product-type axis. Region alone does not determine format. A veterinary marketing authorization filed in the EU uses a different electronic format from a human one filed through the same authority, because the two product classes are governed by different regulations with different technical annexes.
  • Treating NeeS as still current. NeeS (Non-eCTD electronic Submission) was the CTD-structured, folder-and-PDF format used before XML-backbone submissions became mandatory. Regulatory eSubmission roadmaps have progressively withdrawn acceptance of NeeS for new human submissions in favor of eCTD; a pipeline that still defaults to it for a fresh filing is building toward a format the authority no longer accepts.
  • Hardcoding the format at intake instead of deriving it. When the format is a literal string set once in a configuration file, a legitimate exception — a legacy renewal still permitted in the old format, or a small non-ICH market with its own rules — has nowhere to go except a manual override that nobody remembers to audit.

Architecture overview

Selection resolves in a short, ordered chain: identify the target jurisdiction, resolve the product type, check for a legacy exception, then map the remaining combination to a format.

Submission format selection pipeline Five stages left to right: jurisdiction, product type, legacy check, decision table, and selected format. Each stage feeds the next with a single arrow. Jurisdiction Product type Legacy check Decision Format

The three formats differ enough that conflating them in code is what causes the rejections above:

Format Structure XML backbone Current status Typical use
eCTD ICH Common Technical Document tree, Modules 1-5 Yes — index.xml with lifecycle operators Mandatory for FDA human drug applications (NDA/ANDA/BLA) and EMA centralized procedures; the target format most national procedures are migrating to New and lifecycle submissions for human medicinal products
NeeS CTD-structured folders of PDFs No Being withdrawn; most authorities that once accepted it now expect eCTD for new human filings, with narrowing exceptions for legacy sequences already in that format Historical sequences and a shrinking set of national procedures
VNeeS CTD-structured folders of PDFs, veterinary technical annexes No Current, actively required format for veterinary medicinal product submissions in the EU New and lifecycle submissions for veterinary products

VNeeS is easy to mistake for a legacy format because it shares NeeS’s non-XML, folder-based structure, but it is not being retired — it is the format veterinary regulation still specifies, distinct from the human eCTD track and governed by its own module set.

Setup and configuration

The decision rules live in a small, versioned configuration rather than scattered conditionals, so a regulatory-affairs reviewer can audit the rule set without reading code. pyyaml loads it; nothing else is required beyond the standard library.

pip install "pyyaml>=6.0"
# format_rules.yaml — reviewed and versioned alongside the code that reads it
rules:
  - jurisdiction: FDA
    product_type: human
    format: ectd
  - jurisdiction: EMA_CENTRALIZED
    product_type: human
    format: ectd
  - jurisdiction: EMA_NATIONAL
    product_type: human
    format: ectd
    legacy_format: nees
  - jurisdiction: EMA_CENTRALIZED
    product_type: veterinary
    format: vnees
  - jurisdiction: EMA_NATIONAL
    product_type: veterinary
    format: vnees

Configuration is loaded from a path in the environment so the same code runs against a reviewed production rule set or a sandbox variant:

"""Environment-driven configuration for format selection."""
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class SelectorConfig:
    rules_path: str

    @classmethod
    def from_env(cls) -> "SelectorConfig":
        value = os.environ.get("FORMAT_RULES_PATH")
        if not value:
            raise RuntimeError("Missing required environment variable: FORMAT_RULES_PATH")
        return cls(rules_path=value)

Full working implementation

The core model is three enums and a context object; the engine walks the loaded rules and returns a typed result rather than a bare string, so a caller cannot forget to check for the legacy branch.

"""Submission format selection for eCTD, NeeS, and VNeeS."""
from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from enum import Enum

import yaml

logger = logging.getLogger("submission.format_selector")


class Jurisdiction(str, Enum):
    FDA = "FDA"
    EMA_CENTRALIZED = "EMA_CENTRALIZED"
    EMA_NATIONAL = "EMA_NATIONAL"


class ProductType(str, Enum):
    HUMAN = "human"
    VETERINARY = "veterinary"


class SubmissionFormat(str, Enum):
    ECTD = "ectd"
    NEES = "nees"
    VNEES = "vnees"


@dataclass(frozen=True)
class FilingContext:
    """Everything the selector needs to resolve a format."""

    jurisdiction: Jurisdiction
    product_type: ProductType
    is_legacy_sequence: bool = False  # True only for a sequence continuing an existing NeeS lifecycle


@dataclass(frozen=True)
class FormatDecision:
    format: SubmissionFormat
    reason: str


class UnsupportedFilingError(RuntimeError):
    """Raised when no rule matches; never guess a format for a filing."""


class FormatSelector:
    def __init__(self, rules: list[dict]) -> None:
        self._rules = rules

    @classmethod
    def from_yaml(cls, rules_path: str) -> "FormatSelector":
        with open(rules_path, "r", encoding="utf-8") as handle:
            document = yaml.safe_load(handle)
        return cls(document["rules"])

    def select(self, context: FilingContext) -> FormatDecision:
        for rule in self._rules:
            if rule["jurisdiction"] != context.jurisdiction.value:
                continue
            if rule["product_type"] != context.product_type.value:
                continue
            legacy_format = rule.get("legacy_format")
            if context.is_legacy_sequence and legacy_format:
                decision = FormatDecision(
                    format=SubmissionFormat(legacy_format),
                    reason="continuing an existing legacy-format lifecycle",
                )
            else:
                decision = FormatDecision(
                    format=SubmissionFormat(rule["format"]),
                    reason="current rule for jurisdiction and product type",
                )
            self._record_decision(context, decision)
            return decision
        raise UnsupportedFilingError(
            f"no format rule for jurisdiction={context.jurisdiction.value} "
            f"product_type={context.product_type.value}"
        )

    @staticmethod
    def _record_decision(context: FilingContext, decision: FormatDecision) -> None:
        event = {
            "action": "submission_format_selected",
            "jurisdiction": context.jurisdiction.value,
            "product_type": context.product_type.value,
            "is_legacy_sequence": context.is_legacy_sequence,
            "format": decision.format.value,
            "reason": decision.reason,
        }
        logger.info(json.dumps(event, sort_keys=True))

Two properties make this safe to run unattended. First, the engine never returns a default — an unmatched combination raises UnsupportedFilingError and stops the pipeline rather than shipping a guess. Second, every successful selection writes a single structured event, so a reviewer can later answer “why was this filing packaged as VNeeS” from a log line instead of a memory of a conversation.

Validation and edge-case handling

A handful of situations account for nearly every misrouted format in practice:

  • A veterinary product filed alongside a human one under the same sponsor. The two must resolve independently — sharing a jurisdiction is not sharing a product type. Keep ProductType on the filing, not on the sponsor or application record.
  • A national procedure sequence that started life as NeeS. A lifecycle continuation of an already-accepted NeeS sequence generally stays in that format for its remaining life rather than switching mid-stream; the is_legacy_sequence flag exists precisely so a renewal or variation on an old sequence does not get silently upgraded to eCTD in a way the authority’s own records do not expect. Confirm the specific authority’s transition guidance before setting this flag for a new application — it should almost never be true for a brand-new filing.
  • A jurisdiction not in the rule table. Raise, do not fall back to eCTD by convenience. A format assumption for an unmodeled market is exactly the kind of silent decision an inspector will ask you to justify.
  • VNeeS mistaken for a deprecated format. Because VNeeS shares NeeS’s non-XML structure, a rule set copied from a human-only pipeline sometimes marks it “legacy” by association. It is not; treat it as the current, first-class veterinary format with its own row in the table, never as a fallback.

Testing and verification

Table-driven tests against the same YAML fixture the pipeline loads in production catch a bad rule edit before it reaches a real filing.

"""Tests for submission format selection."""
from __future__ import annotations

import pytest


@pytest.fixture
def selector(tmp_path) -> "FormatSelector":
    rules_path = tmp_path / "format_rules.yaml"
    rules_path.write_text(
        "rules:\n"
        "  - jurisdiction: FDA\n"
        "    product_type: human\n"
        "    format: ectd\n"
        "  - jurisdiction: EMA_NATIONAL\n"
        "    product_type: human\n"
        "    format: ectd\n"
        "    legacy_format: nees\n"
        "  - jurisdiction: EMA_NATIONAL\n"
        "    product_type: veterinary\n"
        "    format: vnees\n",
        encoding="utf-8",
    )
    return FormatSelector.from_yaml(str(rules_path))


def test_new_human_filing_selects_ectd(selector: "FormatSelector") -> None:
    context = FilingContext(Jurisdiction.EMA_NATIONAL, ProductType.HUMAN, is_legacy_sequence=False)
    decision = selector.select(context)
    assert decision.format is SubmissionFormat.ECTD


def test_legacy_sequence_stays_nees(selector: "FormatSelector") -> None:
    context = FilingContext(Jurisdiction.EMA_NATIONAL, ProductType.HUMAN, is_legacy_sequence=True)
    decision = selector.select(context)
    assert decision.format is SubmissionFormat.NEES


def test_veterinary_filing_selects_vnees(selector: "FormatSelector") -> None:
    context = FilingContext(Jurisdiction.EMA_NATIONAL, ProductType.VETERINARY)
    decision = selector.select(context)
    assert decision.format is SubmissionFormat.VNEES


def test_unmodeled_combination_raises(selector: "FormatSelector") -> None:
    context = FilingContext(Jurisdiction.FDA, ProductType.VETERINARY)
    with pytest.raises(UnsupportedFilingError):
        selector.select(context)

The four cases cover the paths that matter: the common current-format path, the legacy exception, the veterinary track, and the refusal to guess when nothing matches. Any change to the rule set should extend this fixture before it is trusted in production.

FAQ

Is NeeS still accepted for new human submissions?

Acceptance has been progressively withdrawn as eSubmission roadmaps push authorities toward eCTD for new filings; treat NeeS as valid only for continuing an already-accepted legacy sequence, and confirm the specific authority’s current transition guidance before assuming otherwise.

Why is VNeeS not being retired like NeeS?

VNeeS is the current, purpose-built format for veterinary medicinal product submissions, governed by its own regulation and technical annexes. It shares NeeS’s non-XML, folder-based structure by design, but that structural similarity does not make it a deprecated format — it is the format veterinary filings are expected to use today.

Can a single sponsor mix formats across applications?

Yes, and it is common: a sponsor with both human and veterinary products, or with older sequences still on a legacy lifecycle, will legitimately have some applications in eCTD, some in a retained legacy format, and some in VNeeS at the same time. That is exactly why format selection must be resolved per filing rather than set once per sponsor or system.