Handling eCTD Lifecycle Operations: Replace, Append, Delete

Most sequences in an application’s life do not introduce brand-new documents; they revise, supplement, or retire ones that already exist. Getting the operator wrong — replacing when the intent was to append, or referencing a leaf that no history actually contains — is one of the most common sources of a technical rejection, because the operator is a claim about the application’s entire history, not just the current sequence. This guide implements the four operators in Python: how a leaf’s modified-file reference is resolved against prior sequences, why replace, append, and delete all require a real target, and how delete retires a leaf from view without ever erasing it. It is the concrete implementation behind the lifecycle model introduced in eCTD Sequence Assembly & Lifecycle Management, and it produces the leaf records that feed Generating the eCTD XML Backbone with Python.

An application can easily accumulate dozens of sequences over a product’s life, each one touching only a handful of the hundreds of documents already on file. Without a system of record for what is currently active, correctly choosing an operator becomes a matter of institutional memory — whoever assembled the previous sequence has to remember, or dig up, what they filed and under what leaf ID. A persisted lifecycle history removes that dependency entirely: the correct operator, and whether a given target is even eligible, becomes a lookup rather than a recollection.

Why naive approaches fail

Treating the lifecycle operator as a label attached to a file, rather than a claim checked against history, produces defects that only surface once a sequence has already shipped:

  • No cross-sequence memory. An assembler that only looks at the current sequence cannot tell whether a replace target actually exists anywhere in the application. The check has to run against an index built from every prior sequence, not the one being assembled.
  • Treating delete as erasure. A delete operator retires a leaf from the application’s current view — it stops being considered “in force” — but the audit trail and the sequence that introduced it must remain exactly as they were filed. A lifecycle model that removes historical records to reflect a delete has broken the append-only guarantee the rest of the platform depends on.
  • Allowing a second operation on an already-deleted leaf. Once a leaf has been deleted, a later sequence should not be able to replace or append to it without first re-introducing it; skipping that check lets a sequence silently resurrect a retired document with no explicit operator saying so.
  • String-typed operators. A bare string column for the operation invites typos ("replaced" instead of "replace") that pass through untyped code paths and only fail once the backbone reaches the authority’s schema validator.

Architecture overview

Resolving a lifecycle operation is a small pipeline: build an index of every leaf ever filed, look up the referenced target for the current leaf’s operator, confirm the target’s current state permits the operation, and record the result.

eCTD lifecycle resolution pipeline Five stages left to right: leaf history index, operator lookup, target state check, view update, audit record. Each stage feeds the next with a single arrow. History idx Op lookup State check View update Audit record

Setup and configuration

No third-party dependency is required for the resolution logic itself; it operates on records already produced by assembly. The one configuration value needed is where the application’s lifecycle history is persisted between sequences.

"""Environment-driven location of the persisted lifecycle history."""
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class LifecycleConfig:
    history_store_path: str

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

Full working implementation

The history index tracks every leaf ever filed for an application, together with its current view state — active or retired — so an operation can be checked against what the application actually looks like today, not just what any single prior sequence said.

"""Resolve and validate eCTD lifecycle operations across sequences."""
from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum


class Operation(str, Enum):
    NEW = "new"
    REPLACE = "replace"
    APPEND = "append"
    DELETE = "delete"


class ViewState(str, Enum):
    ACTIVE = "active"
    RETIRED = "retired"


class LifecycleError(Exception):
    """Raised when a leaf's lifecycle operation is invalid against application history."""


@dataclass(frozen=True)
class HistoricalLeaf:
    leaf_id: str
    introduced_in_sequence: str
    state: ViewState


@dataclass
class LifecycleHistory:
    """The full set of leaves ever filed for one application, keyed by leaf ID."""

    leaves: dict[str, HistoricalLeaf] = field(default_factory=dict)

    def record_new(self, leaf_id: str, sequence: str) -> None:
        if leaf_id in self.leaves:
            raise LifecycleError(f"leaf {leaf_id!r} already exists; use replace or append")
        self.leaves[leaf_id] = HistoricalLeaf(leaf_id, sequence, ViewState.ACTIVE)

    def resolve_target(self, operation: Operation, modified_leaf_id: str | None) -> HistoricalLeaf:
        if operation is Operation.NEW:
            raise LifecycleError("new does not resolve a target; call record_new instead")
        if not modified_leaf_id:
            raise LifecycleError(f"{operation.value} requires a modified-file reference")
        target = self.leaves.get(modified_leaf_id)
        if target is None:
            raise LifecycleError(
                f"{operation.value} references {modified_leaf_id!r}, which does not exist in this application's history"
            )
        if target.state is ViewState.RETIRED:
            raise LifecycleError(
                f"{operation.value} references {modified_leaf_id!r}, which was deleted and is not active"
            )
        return target

    def apply_replace(self, new_leaf_id: str, modified_leaf_id: str, sequence: str) -> None:
        self.resolve_target(Operation.REPLACE, modified_leaf_id)
        # The prior leaf remains in history at its original state; only the
        # application's *current view* moves to the new leaf ID.
        self.leaves[modified_leaf_id] = HistoricalLeaf(
            modified_leaf_id, self.leaves[modified_leaf_id].introduced_in_sequence, ViewState.RETIRED
        )
        self.leaves[new_leaf_id] = HistoricalLeaf(new_leaf_id, sequence, ViewState.ACTIVE)

    def apply_append(self, new_leaf_id: str, modified_leaf_id: str, sequence: str) -> None:
        self.resolve_target(Operation.APPEND, modified_leaf_id)
        # Append adds a new, independently active leaf; it does not retire the target.
        self.leaves[new_leaf_id] = HistoricalLeaf(new_leaf_id, sequence, ViewState.ACTIVE)

    def apply_delete(self, modified_leaf_id: str, sequence: str) -> None:
        target = self.resolve_target(Operation.DELETE, modified_leaf_id)
        # Delete only changes the view state; the historical record is preserved
        # in place, so no leaf is ever removed from `self.leaves`.
        self.leaves[modified_leaf_id] = HistoricalLeaf(
            modified_leaf_id, target.introduced_in_sequence, ViewState.RETIRED
        )

apply_replace, apply_append, and apply_delete all call resolve_target first, so an operation against a leaf that does not exist — or one that has already been retired — raises LifecycleError before any state changes, keeping the history internally consistent even when a batch of leaves is only partially valid.

Note also what apply_replace deliberately does not do: it does not delete self.leaves[modified_leaf_id] and insert a fresh entry in its place. It reassigns the existing dictionary value to a new HistoricalLeaf with the same introduced_in_sequence it always had, only flipping state to RETIRED. That distinction — mutating a state field versus removing a record — is the entire difference between an append-only lifecycle model and one that quietly loses history the moment a sponsor stops needing to look at it.

Validation and edge-case handling

A handful of cases determine whether the model is actually append-only in practice, not just in name:

  • Re-introducing a retired leaf. A leaf that was deleted in an earlier sequence should never simply come back to active through a replace or append that targets it — resolve_target explicitly rejects operations against a RETIRED leaf. If a sponsor genuinely needs to re-file a previously withdrawn document, that has to be modeled as a new leaf with its own identity, so the history shows an explicit re-introduction rather than an implicit resurrection.
  • A replace chain spanning many sequences. Because apply_replace always reads the current state of modified_leaf_id from the live index rather than from any single prior sequence, a document that has been replaced three times in three different sequences still resolves correctly — the fourth replace targets whichever leaf ID is presently active, and the full chain remains reconstructable by walking introduced_in_sequence backward.
  • Same leaf targeted twice in one sequence. Two leaves in the same sequence that both declare modified-file pointing at the same target is a defect this history model surfaces naturally: the first apply_replace call retires the target and installs a new active leaf, so the second call’s resolve_target finds the original target already RETIRED and raises.
  • Delete with no later re-filing. A deleted leaf simply stays RETIRED forever, which is the correct and expected terminal state — nothing further needs to happen, and no cleanup process should ever prune it from self.leaves, because that history is exactly what an inspector reconstructs the application from.

Every state transition here is also a fact worth recording independently of the in-memory index — the append-only audit log is the durable copy of this history, so a lost or corrupted LifecycleHistory object can always be rebuilt from the log rather than from the sequences on disk alone. In practice that means every call to apply_replace, apply_append, or apply_delete should be paired with an audit event carrying the same three fields the in-memory record holds — leaf ID, sequence, and resulting state — so the two representations never have the chance to drift apart, and a rebuild after an incident reproduces the exact same view an inspector would have seen at filing time.

Testing and verification

The tests that matter most are the ones proving illegal operations are rejected and that a replace chain resolves against current, not historical, state.

"""Tests for eCTD lifecycle operation resolution."""
from __future__ import annotations

import pytest


def test_replace_without_existing_target_raises() -> None:
    history = LifecycleHistory()
    with pytest.raises(LifecycleError):
        history.apply_replace("new-leaf", "never-filed", sequence="0001")


def test_replace_chain_targets_current_active_leaf() -> None:
    history = LifecycleHistory()
    history.record_new("leaf-a", sequence="0000")
    history.apply_replace("leaf-b", "leaf-a", sequence="0001")
    history.apply_replace("leaf-c", "leaf-b", sequence="0002")

    assert history.leaves["leaf-a"].state is ViewState.RETIRED
    assert history.leaves["leaf-b"].state is ViewState.RETIRED
    assert history.leaves["leaf-c"].state is ViewState.ACTIVE


def test_operation_against_deleted_leaf_is_rejected() -> None:
    history = LifecycleHistory()
    history.record_new("leaf-a", sequence="0000")
    history.apply_delete("leaf-a", sequence="0001")

    with pytest.raises(LifecycleError):
        history.apply_append("leaf-b", "leaf-a", sequence="0002")

The first test proves a dangling reference is caught immediately; the second proves a three-sequence replace chain leaves exactly one leaf active and the rest correctly retired; the third proves a retired leaf cannot silently become a valid target for a later operation.