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
replacetarget 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
deleteoperator 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
replaceorappendto 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.
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
activethrough areplaceorappendthat targets it —resolve_targetexplicitly rejects operations against aRETIREDleaf. If a sponsor genuinely needs to re-file a previously withdrawn document, that has to be modeled as anewleaf 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_replacealways reads the current state ofmodified_leaf_idfrom 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 walkingintroduced_in_sequencebackward. - Same leaf targeted twice in one sequence. Two leaves in the same sequence that both declare
modified-filepointing at the same target is a defect this history model surfaces naturally: the firstapply_replacecall retires the target and installs a new active leaf, so the second call’sresolve_targetfinds the original target alreadyRETIREDand raises. - Delete with no later re-filing. A deleted leaf simply stays
RETIREDforever, which is the correct and expected terminal state — nothing further needs to happen, and no cleanup process should ever prune it fromself.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.