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.
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_worklistis a point-in-time snapshot; regenerating it after new enrollments naturally picks up the new participant because theirconsented_versionwill still be behind the amendment’sversion_number. Re-running the generator is safe — it produces tasks per participant, and a caller should skip creating a duplicate task for aparticipant_idthat already has one for the sameamendment_id. - A participant withdraws from the study before completing re-consent.
WITHDRAWN_FROM_STUDYis a legal terminal transition from eitherPENDING,SCHEDULED, orOVERDUE, 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.
DECLINEDis 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_overdueis a pure read — computed on demand fromstatusanddue_date— used to drive a reminder or an escalation list. Nothing in this design flips the storedstatustoOVERDUEautomatically on a timer; a scheduled job callsrecord_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.