Detecting a Missing Delegation of Authority Log
A Delegation of Authority (DoA) log is the record that names, for every task ICH E6 GCP treats as delegable — obtaining informed consent, assessing adverse events, dispensing investigational product, maintaining source documentation — exactly which qualified staff member the principal investigator has authorized to perform it, and when that authorization started and ended. A site cannot activate on a DoA log that is missing entirely, that names a task with no delegatee, or that carries an entry nobody signed. This walkthrough builds the detector that catches those gaps before a monitor does. It is one of the concrete failure modes covered by Checklist Sync & Gap Analysis, within the broader Automated Document Ingestion & Validation Workflows area, and it shares its severity model with the reconciliation engine in Automating Checklist Synchronization Between EDC and CTMS. Two sibling activation blockers deserve the same rigor: an expired IRB approval and a stale SOP acknowledgement.
Why naive approaches fail
Most sites still track delegation on a scanned, hand-signed PDF that a coordinator uploads once and nobody re-checks. That approach fails in ways a program only discovers during an inspection:
- A task with no delegatee at all is invisible. If the log only lists staff who are delegated, there is no record showing that “informed consent” was ever considered — the absence produces no error, no ticket, and no signal.
- An unsigned or undated entry looks complete on a quick read. A coordinator’s name typed into a spreadsheet cell without the PI’s signature and a date satisfies a visual checklist review but fails ICH E6(R2) §4.1, which requires the PI to maintain a list of appropriately qualified persons to whom significant trial-related duties have been delegated.
- Training and qualification drift silently. A delegate whose Good Clinical Practice training lapsed six months ago is still “on the log,” and nothing re-evaluates whether they remain qualified for the tasks assigned to them.
- Delegation windows are treated as permanent. A sub-investigator who left the study in March may still show as actively delegated in July if nobody records an end date, so the log no longer reflects who is actually performing the work.
- Manual review does not scale. A 150-site program cannot have a human re-read every DoA log every week; the gaps above accumulate quietly until a monitoring visit or an FDA Form 483 surfaces them.
The fix is the same discipline the rest of the platform applies elsewhere: model the log as typed, comparable data, run a deterministic detector against it on every change, and write every finding to an auditable record rather than a human’s memory.
Architecture overview
The detector is a short pipeline: load the site’s required task list and staff directory, load the current DoA entries, cross-check each required task against its delegatee’s qualifications and the entry’s completeness, and emit both a findings list and an audit record.
The output of this pipeline is one of the hard gates consumed by Clinical Site Readiness Assessment Frameworks: a site with an open critical DoA gap cannot cross the activation threshold no matter how complete its other artifacts are, and its ethics status still has to independently clear the IRB/Ethics Workflow Mapping state machine before enrollment.
Setup and configuration
The detector has no external dependencies beyond the standard library; install pytest for the test suite.
pip install "pytest>=8.0"
Configuration — which tasks are activation-blocking and how much lead time a training credential needs before it counts as “about to lapse” — is policy, not code, so it is read from the environment rather than hardcoded:
"""Environment-driven configuration for DoA gap detection."""
from __future__ import annotations
import os
from dataclasses import dataclass
def _required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
@dataclass(frozen=True)
class DoaConfig:
required_task_codes: frozenset[str]
training_grace_days: int
@classmethod
def from_env(cls) -> "DoaConfig":
codes = _required_env("DOA_REQUIRED_TASK_CODES").split(",")
grace = int(os.environ.get("DOA_TRAINING_GRACE_DAYS", "30"))
return cls(
required_task_codes=frozenset(c.strip() for c in codes if c.strip()),
training_grace_days=grace,
)
DOA_REQUIRED_TASK_CODES is a comma-separated list such as INFORMED_CONSENT,AE_ASSESSMENT,IP_ACCOUNTABILITY,SOURCE_DOCUMENTATION, kept in the sponsor’s version-controlled configuration so regulatory affairs can add a task without a code deployment.
Full working implementation
Model the staff directory, the log entries, and the gap findings as immutable, typed structures so a detector run can never mutate the record it is inspecting.
"""Detect gaps in a site's delegation of authority log."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timezone
from enum import Enum
class GapType(str, Enum):
"""Distinct reasons a delegated task fails the gap check."""
NO_DELEGATE = "no_delegate"
UNSIGNED_ENTRY = "unsigned_entry"
UNQUALIFIED_DELEGATE = "unqualified_delegate"
EXPIRED_DELEGATION = "expired_delegation"
TRAINING_LAPSED = "training_lapsed"
class Severity(str, Enum):
CRITICAL = "P0"
HIGH = "P1"
@dataclass(frozen=True, slots=True)
class StaffRecord:
"""One staff member eligible for delegation at a site."""
staff_id: str
full_name: str
role: str
gcp_training_current: bool
training_expires_on: date | None
@dataclass(frozen=True, slots=True)
class DelegationEntry:
"""One row of a site's delegation of authority log."""
site_id: str
task_code: str
staff_id: str | None
delegated_by: str | None # PI who signed; None means unsigned
signed_on: date | None
effective_from: date | None
effective_until: date | None
@dataclass(frozen=True, slots=True)
class DoaGap:
"""One detected delegation gap for a single task at a site."""
site_id: str
task_code: str
gap_type: GapType
severity: Severity
detail: str
blocks_activation: bool
def detect_doa_gaps(
site_id: str,
required_task_codes: frozenset[str],
entries: list[DelegationEntry],
staff_directory: dict[str, StaffRecord],
as_of: date,
training_grace_days: int,
) -> list[DoaGap]:
"""Return every delegation gap for one site as of a given date.
Every required task must resolve to exactly one current, signed,
dated entry whose delegate holds current GCP training. Anything
short of that is a gap; the function never guesses a delegate.
"""
by_task: dict[str, list[DelegationEntry]] = {}
for entry in entries:
if entry.site_id == site_id:
by_task.setdefault(entry.task_code, []).append(entry)
gaps: list[DoaGap] = []
for task_code in sorted(required_task_codes):
candidates = [
e for e in by_task.get(task_code, [])
if e.effective_from is not None and e.effective_from <= as_of
]
if not candidates:
gaps.append(DoaGap(
site_id, task_code, GapType.NO_DELEGATE, Severity.CRITICAL,
"no delegation entry covers this task as of the evaluation date",
blocks_activation=True,
))
continue
# Prefer the entry with no expiry, or the latest expiry, as current.
entry = max(candidates, key=lambda e: e.effective_until or date.max)
if entry.staff_id is None or entry.delegated_by is None or entry.signed_on is None:
gaps.append(DoaGap(
site_id, task_code, GapType.UNSIGNED_ENTRY, Severity.CRITICAL,
"entry is missing a delegatee, PI signature, or signature date",
blocks_activation=True,
))
continue
if entry.effective_until is not None and entry.effective_until < as_of:
gaps.append(DoaGap(
site_id, task_code, GapType.EXPIRED_DELEGATION, Severity.CRITICAL,
f"delegation ended {entry.effective_until.isoformat()}",
blocks_activation=True,
))
continue
staff = staff_directory.get(entry.staff_id)
if staff is None:
gaps.append(DoaGap(
site_id, task_code, GapType.UNQUALIFIED_DELEGATE, Severity.CRITICAL,
f"delegate {entry.staff_id!r} is not in the qualified staff directory",
blocks_activation=True,
))
continue
if not staff.gcp_training_current:
gaps.append(DoaGap(
site_id, task_code, GapType.TRAINING_LAPSED, Severity.CRITICAL,
f"{staff.full_name} does not hold current GCP training",
blocks_activation=True,
))
continue
if staff.training_expires_on is not None:
days_left = (staff.training_expires_on - as_of).days
if 0 <= days_left <= training_grace_days:
gaps.append(DoaGap(
site_id, task_code, GapType.TRAINING_LAPSED, Severity.HIGH,
f"{staff.full_name}'s GCP training expires in {days_left} days",
blocks_activation=False,
))
return gaps
detect_doa_gaps never infers a missing delegate from context — a task with no covering entry is always a NO_DELEGATE gap, never silently skipped. Every branch that finds a problem returns immediately with a specific GapType, so a remediation ticket routes to the right owner: an unsigned entry goes back to the PI, an unqualified delegate goes to training, an expired delegation goes to the coordinator to re-delegate the task.
Emit one structured, append-only audit event per detector run, mirroring the pattern used across the platform’s append-only audit log:
"""Emit an audit event for a completed DoA gap-detection run."""
from __future__ import annotations
import json
import logging
logger = logging.getLogger("checklist_sync.doa_gap_detection")
def record_doa_check(site_id: str, as_of: date, gaps: list[DoaGap], principal: str) -> None:
event = {
"action": "doa_log_gap_check",
"site_id": site_id,
"as_of": as_of.isoformat(),
"checked_at": datetime.now(timezone.utc).isoformat(),
"principal": principal,
"gap_count": len(gaps),
"activation_blocked": any(g.blocks_activation for g in gaps),
"gaps": [
{"task_code": g.task_code, "gap_type": g.gap_type.value, "severity": g.severity.value}
for g in gaps
],
}
logger.info(json.dumps(event, sort_keys=True))
Persist this record to write-once storage rather than a mutable table row; the point of the audit trail is that a site’s activation history — including every DoA re-check it ever failed — is reconstructable, not just its current state.
Validation and edge-case handling
Several DoA shapes look fine on a cursory read but are not:
- Two overlapping entries for the same task. A coordinator who re-delegates a task without closing out the prior entry leaves two active rows.
detect_doa_gapsresolves this deterministically by preferring the entry with the later (or absent)effective_until, but a production system should also flag the overlap itself as a data-quality issue for the coordinator to close. - A delegate on the log who is not on the site’s staff roster. This is not the same as an unqualified delegate — it usually means the log references a staff member who transferred or left. Treat it as
UNQUALIFIED_DELEGATErather than assuming they are still eligible. - A signed entry with a future
signed_ondate. A backdated or forward-dated signature is itself a data-integrity problem separate from completeness; validate thatsigned_onfalls on or beforeeffective_fromand reject the entry rather than accepting it as covering the task. - A task delegated to more than one person concurrently. Some tasks (for example, source documentation) legitimately have multiple current delegates; the detector should be run per-delegate-slot when a site’s protocol requires more than one qualified backup, rather than assuming one entry per task is always sufficient.
- Training that expires mid-study. A near-expiry finding (
TRAINING_LAPSEDatHIGHseverity) does not block activation but must still surface, because the same delegate will trip a hard gate a few weeks later if nobody renews their certification.
Testing and verification
Pin the detector’s behavior with pytest against the shapes that most often slip through manual review: a missing delegate, an unsigned entry, and an unqualified delegate.
"""Tests for delegation of authority gap detection."""
from __future__ import annotations
from datetime import date
REQUIRED = frozenset({"INFORMED_CONSENT", "AE_ASSESSMENT"})
TODAY = date(2026, 7, 17)
def _staff(training_current: bool = True) -> dict[str, StaffRecord]:
return {
"S001": StaffRecord("S001", "Dr. A. Reyes", "Sub-I", training_current, date(2027, 1, 1)),
}
def test_no_delegate_is_critical_gap() -> None:
gaps = detect_doa_gaps("SITE-014", REQUIRED, entries=[], staff_directory=_staff(),
as_of=TODAY, training_grace_days=30)
codes = {g.task_code for g in gaps}
assert codes == REQUIRED
assert all(g.gap_type is GapType.NO_DELEGATE and g.blocks_activation for g in gaps)
def test_unsigned_entry_blocks_activation() -> None:
entry = DelegationEntry(
site_id="SITE-014", task_code="INFORMED_CONSENT", staff_id="S001",
delegated_by=None, signed_on=None,
effective_from=date(2026, 1, 1), effective_until=None,
)
gaps = detect_doa_gaps("SITE-014", frozenset({"INFORMED_CONSENT"}), [entry], _staff(),
TODAY, training_grace_days=30)
assert gaps[0].gap_type is GapType.UNSIGNED_ENTRY
assert gaps[0].blocks_activation is True
def test_unqualified_delegate_flagged() -> None:
entry = DelegationEntry(
site_id="SITE-014", task_code="AE_ASSESSMENT", staff_id="S999",
delegated_by="Dr. PI", signed_on=date(2026, 1, 1),
effective_from=date(2026, 1, 1), effective_until=None,
)
gaps = detect_doa_gaps("SITE-014", frozenset({"AE_ASSESSMENT"}), [entry], _staff(),
TODAY, training_grace_days=30)
assert gaps[0].gap_type is GapType.UNQUALIFIED_DELEGATE
def test_fully_delegated_task_produces_no_gap() -> None:
entry = DelegationEntry(
site_id="SITE-014", task_code="AE_ASSESSMENT", staff_id="S001",
delegated_by="Dr. PI", signed_on=date(2026, 1, 1),
effective_from=date(2026, 1, 1), effective_until=None,
)
gaps = detect_doa_gaps("SITE-014", frozenset({"AE_ASSESSMENT"}), [entry], _staff(),
TODAY, training_grace_days=30)
assert gaps == []
The fourth test is the one worth reading twice: it proves the detector produces an empty findings list — not a passing placeholder — when every required task is properly, currently, and legibly delegated. That absence of noise is what keeps the detector trustworthy at scale; a tool that always finds something trains its users to ignore it.
FAQ
What counts as a delegable task under ICH E6 GCP?
ICH E6(R2) leaves the specific list to the protocol and the sponsor’s SOPs, but it requires that any significant trial-related duty delegated by the investigator be recorded, along with the delegate’s qualifications. In practice sponsors publish a fixed task list — informed consent, adverse event assessment, investigational product accountability, and source documentation are near-universal — and encode it as the required-task configuration this detector checks against.
Why does an unsigned entry block activation instead of just producing a warning?
An unsigned or undated delegation entry cannot establish that the principal investigator actually authorized the task, which is the entire regulatory purpose of the log. Treating it as anything less than a critical, activation-blocking gap would let a site enroll subjects on work performed by staff nobody can prove was authorized to perform it.
How does this differ from the general checklist synchronization problem?
Checklist synchronization reconciles the state of an artifact across systems that each hold a partial view. Delegation-log gap detection is narrower and deeper: it validates the internal structure and staffing correctness of one specific artifact. The two compose — a DoA log can pass this detector and still show as “not yet filed” in the eTMF, which is exactly the kind of cross-system gap Checklist Sync & Gap Analysis is built to catch.