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.

DoA log gap-detection pipeline Five stages left to right: required tasks and staff directory, DoA log entries, cross-check against qualification and signature, gap classification, and audit record with activation hold. Tasks + staff DoA entries Cross-check Classify gap Audit + hold

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_gaps resolves 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_DELEGATE rather than assuming they are still eligible.
  • A signed entry with a future signed_on date. A backdated or forward-dated signature is itself a data-integrity problem separate from completeness; validate that signed_on falls on or before effective_from and 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_LAPSED at HIGH severity) 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.