Automating Re-Consent Workflows After a Protocol Amendment

When a protocol amendment changes anything that bears on a participant’s willingness to continue — a new procedure, an added risk, a revised schedule of visits — the participants already enrolled at an affected site have to be re-consented before the change touches their care. Getting the scope of that exercise wrong in either direction is a compliance problem: re-consenting participants who were never affected wastes site staff time and can itself confuse subjects, while missing participants who should have been re-consented is a documented deviation. This page builds the automation that determines who needs to be worked, generates the work list, tracks it against a deadline, and writes every outcome to the trail — sitting downstream of Tracking Protocol Amendments Across Central and Local IRBs and the coordinating layer in Automating Multi-Board IRB Submissions with a State Machine, both part of IRB/Ethics Workflow Mapping within Core Architecture & Regulatory Mapping for Clinical Trials. One point governs everything that follows: automation determines who needs to be asked and by when, but the re-consent decision itself is made by a participant and documented by a human — no code path in this pipeline manufactures a consent outcome.

Why naive approaches fail

Re-consent gets handled as an email blast far more often than the regulatory stakes justify, and each shortcut below produces a gap that only surfaces during monitoring or inspection:

  • Blasting every enrolled participant at every site. Not every amendment is substantive, and not every site is even cleared to implement a given amendment yet — a site whose local board has not approved the amendment should not be running re-consent for it at all. A blanket notification ignores both facts and over-collects consent that was never required, or under-collects it because the message went out before the site was actually authorized to implement the change.
  • No link to the amendment’s substantive-ness flag. An administrative amendment — a typo fix, a contact-information update — does not trigger re-consent under most IRB policies. Treating every amendment identically either causes needless outreach or, worse, trains site staff to ignore re-consent notices altogether.
  • No deadline, or a deadline nobody tracks. A re-consent that drags on indefinitely leaves participants continuing in a study under materially outdated information. Without a tracked due date, the fact that it is overdue is invisible until a monitor asks for it directly.
  • The system records “re-consented” as a status flip, not a documented decision. A binary flag that flips to true does not capture who obtained consent, by what method, and when — the attributable, contemporaneous record 21 CFR Part 11 and ALCOA+ both require, and the record a monitor will ask to see for every participant on the list.

Architecture overview

Once an amendment’s board approvals authorize a site to implement it, the pipeline determines the affected participants, builds a dated work list, and then only tracks — it never decides — completion.

Re-consent workflow pipeline Five stages left to right: the amendment approved and implementable at a site, identification of currently enrolled participants affected by it, generation of the dated re-consent work list, tracking of completion against the deadline, and the audit record capturing each participant's documented decision. Approved Identify Worklist Track Audit log

Setup and configuration

Nothing here needs a package beyond the standard library. The one policy value worth pulling out of code is the default re-consent window — how many days site staff have to complete outreach once the work list is generated — because regulatory affairs may tighten it for a higher-risk amendment without a code change.

python -m venv .venv && source .venv/bin/activate
python -m pip install --upgrade pip
"""Environment-driven configuration for re-consent tracking."""
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class ReConsentConfig:
    default_window_days: int

    @classmethod
    def from_env(cls) -> "ReConsentConfig":
        raw = os.environ.get("RECONSENT_DEFAULT_WINDOW_DAYS", "30")
        try:
            days = int(raw)
        except ValueError as exc:
            raise RuntimeError(f"RECONSENT_DEFAULT_WINDOW_DAYS must be an integer, got {raw!r}") from exc
        if days <= 0:
            raise RuntimeError("RECONSENT_DEFAULT_WINDOW_DAYS must be a positive integer")
        return cls(default_window_days=days)

Full working implementation

Three pieces do the work: a plain record of who is currently enrolled and under which consent version, a ReConsentTask per participant with an explicit, guarded status, and a generator that builds the work list only for participants a substantive, implementable amendment actually affects. The status transition into a completed or declined state always requires the identity of the human who obtained or recorded the decision — the automation itself is never that actor.

"""Generate and track re-consent work after a protocol amendment."""
from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from enum import Enum

logger = logging.getLogger("reconsent_tracker")


class ReConsentStatus(str, Enum):
    PENDING = "PENDING"
    SCHEDULED = "SCHEDULED"
    COMPLETED = "COMPLETED"
    DECLINED = "DECLINED"
    WITHDRAWN_FROM_STUDY = "WITHDRAWN_FROM_STUDY"
    OVERDUE = "OVERDUE"


RECONSENT_TRANSITIONS: dict[ReConsentStatus, frozenset[ReConsentStatus]] = {
    ReConsentStatus.PENDING: frozenset(
        {ReConsentStatus.SCHEDULED, ReConsentStatus.COMPLETED, ReConsentStatus.DECLINED,
         ReConsentStatus.WITHDRAWN_FROM_STUDY, ReConsentStatus.OVERDUE}
    ),
    ReConsentStatus.SCHEDULED: frozenset(
        {ReConsentStatus.COMPLETED, ReConsentStatus.DECLINED,
         ReConsentStatus.WITHDRAWN_FROM_STUDY, ReConsentStatus.OVERDUE}
    ),
    ReConsentStatus.OVERDUE: frozenset(
        {ReConsentStatus.SCHEDULED, ReConsentStatus.COMPLETED, ReConsentStatus.DECLINED,
         ReConsentStatus.WITHDRAWN_FROM_STUDY}
    ),
    ReConsentStatus.COMPLETED: frozenset(),           # terminal
    ReConsentStatus.DECLINED: frozenset(),            # terminal — routes to the IRB, not retried automatically
    ReConsentStatus.WITHDRAWN_FROM_STUDY: frozenset(),  # terminal
}


class IllegalReConsentTransitionError(RuntimeError):
    """Raised when a task's requested status move is outside RECONSENT_TRANSITIONS."""


@dataclass(frozen=True)
class EnrolledParticipant:
    """A currently enrolled participant and the consent version they last signed."""

    participant_id: str
    site_id: str
    consented_version: int
    enrollment_status: str  # e.g. "active", "completed", "withdrawn"


@dataclass(frozen=True)
class ReConsentDecisionEvent:
    """An immutable, attributable record of one status change on a re-consent task."""

    participant_id: str
    amendment_id: str
    status_from: ReConsentStatus
    status_to: ReConsentStatus
    recorded_by: str   # the human who obtained or documented the decision
    method: str        # e.g. "in_person", "telephone_with_witness"
    timestamp: datetime


@dataclass
class ReConsentTask:
    """One participant's re-consent obligation for one amendment."""

    participant_id: str
    site_id: str
    amendment_id: str
    due_date: date
    status: ReConsentStatus = ReConsentStatus.PENDING
    history: list[ReConsentDecisionEvent] = field(default_factory=list)

    def record_decision(
        self, target: ReConsentStatus, recorded_by: str, method: str,
    ) -> ReConsentDecisionEvent:
        if not recorded_by:
            raise ValueError("recorded_by is required — a re-consent decision must be attributable to a person")
        if target not in RECONSENT_TRANSITIONS[self.status]:
            allowed = sorted(s.value for s in RECONSENT_TRANSITIONS[self.status])
            raise IllegalReConsentTransitionError(
                f"{self.participant_id}/{self.amendment_id}: {self.status.value} -> {target.value} "
                f"not permitted; allowed: {allowed}"
            )
        event = ReConsentDecisionEvent(
            participant_id=self.participant_id,
            amendment_id=self.amendment_id,
            status_from=self.status,
            status_to=target,
            recorded_by=recorded_by,
            method=method,
            timestamp=datetime.now(timezone.utc),
        )
        self.status = target
        self.history.append(event)
        logger.info(json.dumps({
            "action": "reconsent_decision",
            "participant_id": self.participant_id,
            "amendment_id": self.amendment_id,
            "site_id": self.site_id,
            "from": event.status_from.value,
            "to": event.status_to.value,
            "recorded_by": recorded_by,
            "method": method,
        }, sort_keys=True))
        return event

    def is_overdue(self, as_of: date) -> bool:
        return self.status in {ReConsentStatus.PENDING, ReConsentStatus.SCHEDULED} and as_of > self.due_date


def generate_reconsent_worklist(
    *,
    amendment_id: str,
    amendment_version_number: int,
    amendment_is_substantive: bool,
    site_id: str,
    site_may_implement: bool,
    enrolled_participants: list[EnrolledParticipant],
    window_days: int,
    generated_on: date,
) -> list[ReConsentTask]:
    """Build the re-consent work list for one site once its board has cleared the amendment.

    Only participants who are still active and who consented to an earlier
    version are included. Nothing here records a decision — it only
    determines scope and a deadline.
    """
    if not amendment_is_substantive:
        return []
    if not site_may_implement:
        return []
    due = generated_on + timedelta(days=window_days)
    tasks = [
        ReConsentTask(
            participant_id=participant.participant_id,
            site_id=participant.site_id,
            amendment_id=amendment_id,
            due_date=due,
        )
        for participant in enrolled_participants
        if participant.site_id == site_id
        and participant.enrollment_status == "active"
        and participant.consented_version < amendment_version_number
    ]
    logger.info(json.dumps({
        "action": "reconsent_worklist_generated",
        "amendment_id": amendment_id,
        "site_id": site_id,
        "task_count": len(tasks),
        "due_date": due.isoformat(),
    }, sort_keys=True))
    return tasks

generate_reconsent_worklist is intentionally narrow: it returns an empty list — not an error, not a placeholder decision — whenever the amendment is not substantive or the site is not yet cleared to implement it, so calling it early or on the wrong amendment never manufactures work that should not exist. Every task it does produce starts in PENDING and can only move forward through record_decision, which refuses to proceed without the identity of the person who obtained the outcome.

Validation and edge-case handling

Four situations are worth handling explicitly, because each is a plausible way an audit finds a gap between the work list and reality:

  • A participant enrolls after the work list was generated. generate_reconsent_worklist is a point-in-time snapshot; regenerating it after new enrollments naturally picks up the new participant because their consented_version will still be behind the amendment’s version_number. Re-running the generator is safe — it produces tasks per participant, and a caller should skip creating a duplicate task for a participant_id that already has one for the same amendment_id.
  • A participant withdraws from the study before completing re-consent. WITHDRAWN_FROM_STUDY is a legal terminal transition from either PENDING, SCHEDULED, or OVERDUE, so the task closes with an honest reason rather than sitting open or being silently deleted.
  • A participant declines to continue under the amended protocol. DECLINED is terminal and, by design, is not something this pipeline auto-resolves — a decline is routed to the study team and, where required, back to the IRB as a subject-discontinuation event; the automation’s job ends at recording the decision faithfully.
  • A task ages past its due date with no decision recorded. is_overdue is a pure read — computed on demand from status and due_date — used to drive a reminder or an escalation list. Nothing in this design flips the stored status to OVERDUE automatically on a timer; a scheduled job calls record_decision(ReConsentStatus.OVERDUE, ...) explicitly, attributed to the system account that runs the check, so even an automatically detected lapse is still an explicit, logged transition rather than a silent status drift.

The rule that ties all four together: no participant’s re-consent outcome is ever set by inferring it from elapsed time or from another participant’s outcome. Every terminal status traces back to one record_decision call with a named actor and a method.

Testing and verification

The tests below check the two guarantees that matter most under monitoring: the work list contains exactly the participants who need it and no one else, and a decision can never be recorded without an attributable actor.

"""Tests for re-consent work-list generation and decision tracking."""
from __future__ import annotations

from datetime import date

import pytest


def test_worklist_excludes_non_substantive_amendments() -> None:
    participants = [
        EnrolledParticipant("P-001", site_id="014", consented_version=2, enrollment_status="active"),
    ]
    tasks = generate_reconsent_worklist(
        amendment_id="AMD-004", amendment_version_number=3, amendment_is_substantive=False,
        site_id="014", site_may_implement=True, enrolled_participants=participants,
        window_days=30, generated_on=date(2026, 7, 15),
    )
    assert tasks == []


def test_worklist_includes_only_active_participants_behind_the_new_version() -> None:
    participants = [
        EnrolledParticipant("P-001", site_id="014", consented_version=2, enrollment_status="active"),
        EnrolledParticipant("P-002", site_id="014", consented_version=3, enrollment_status="active"),
        EnrolledParticipant("P-003", site_id="014", consented_version=2, enrollment_status="withdrawn"),
        EnrolledParticipant("P-004", site_id="022", consented_version=2, enrollment_status="active"),
    ]
    tasks = generate_reconsent_worklist(
        amendment_id="AMD-004", amendment_version_number=3, amendment_is_substantive=True,
        site_id="014", site_may_implement=True, enrolled_participants=participants,
        window_days=30, generated_on=date(2026, 7, 15),
    )
    assert [task.participant_id for task in tasks] == ["P-001"]
    assert tasks[0].due_date == date(2026, 8, 14)


def test_decision_requires_a_named_actor() -> None:
    task = ReConsentTask(participant_id="P-001", site_id="014", amendment_id="AMD-004", due_date=date(2026, 8, 14))
    with pytest.raises(ValueError):
        task.record_decision(ReConsentStatus.COMPLETED, recorded_by="", method="in_person")


def test_completed_is_terminal() -> None:
    task = ReConsentTask(participant_id="P-001", site_id="014", amendment_id="AMD-004", due_date=date(2026, 8, 14))
    task.record_decision(ReConsentStatus.COMPLETED, recorded_by="coordinator_jsmith", method="in_person")
    with pytest.raises(IllegalReConsentTransitionError):
        task.record_decision(ReConsentStatus.DECLINED, recorded_by="coordinator_jsmith", method="in_person")

The second test is the one worth reading closely: of four enrolled participants, only the one who is both active and still on an older consent version at the right site is included, which is exactly the scoping failure a blanket notification gets wrong. The third and fourth tests confirm the record can never be attributed to no one, and that a completed re-consent cannot later be silently overwritten by a different outcome.