Enrollment Date / Anchor Date Service Design¶
Executive Summary¶
This document specifies the design for a flexible Enrollment Date (anchor date) service that supports multiple source types, audit-compliant change tracking, and predictable schedule (re)generation. The design integrates with the existing ODM-aligned scheduling infrastructure while providing governance controls appropriate for clinical trials.
1. Architecture Summary¶
Components and Responsibilities¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ Portal / Patient Portal │
│ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────────────────┐ │
│ │ Study Setup: │ │ Participant View:│ │ Consent Workflow: │ │
│ │ Enrollment Date │ │ Enrollment Panel │ │ Auto-propose anchor on │ │
│ │ Policy Config │ │ + History │ │ consent completion │ │
│ └────────┬─────────┘ └────────┬─────────┘ └──────────────┬──────────────┘ │
└───────────┼─────────────────────┼───────────────────────────┼───────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ API Layer (FastAPI) │
│ ┌──────────────────────────────────────────────────────────────────────────┐│
│ │ POST /api/studies/{study_id}/config/enrollment-policy ││
│ │ GET /api/studies/{study_id}/config/enrollment-policy ││
│ │ POST /api/studies/{study_id}/participants/{id}/anchor-date ││
│ │ GET /api/studies/{study_id}/participants/{id}/anchor-date/history ││
│ │ POST /api/studies/{study_id}/participants/{id}/anchor-date/override ││
│ └──────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ EnrollmentDateService │ │
│ │ │ │
│ │ - evaluate_anchor_sources() Evaluate eligible sources per policy │ │
│ │ - propose_enrollment_date() Set provisional date │ │
│ │ - finalize_enrollment_date() Finalize when prerequisites met │ │
│ │ - override_enrollment_date() Manual override with reason/audit │ │
│ │ - get_enrollment_date_history() Retrieve change history │ │
│ │ - validate_change() Check if change allowed per policy │ │
│ │ │ │
│ │ Events emitted: │ │
│ │ ENROLLMENT_DATE_PROPOSED, ENROLLMENT_DATE_SET, │ │
│ │ ENROLLMENT_DATE_CHANGED, ENROLLMENT_DATE_OVERRIDE_GRANTED │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ ScheduleVersioningService │ │
│ │ │ │
│ │ - create_schedule_version() Generate new schedule from anchor │ │
│ │ - regenerate_schedule() Re-anchor existing schedule │ │
│ │ - reconcile_completed_visits() Handle visits already completed │ │
│ │ - get_schedule_history() Retrieve version history │ │
│ │ │ │
│ │ Integrates with: │ │
│ │ - UnifiedSchedulerService (legacy/EDC scheduling) │ │
│ │ - BusinessDayService (weekend/holiday avoidance) │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ │
│ ┌────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ ParticipantAnchor │ │ ParticipantAnchor │ │ ScheduleVersion │ │
│ │ Date │ │ DateHistory │ │ │ │
│ │ │ │ (immutable ledger) │ │ │ │
│ └────────────────────┘ └──────────────────────┘ └─────────────────────┘ │
│ │
│ ┌────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ Participant │ │ ConsentInstance │ │ AuditLog │ │
│ │ (enrollment_date) │ │ (consent_timestamp) │ │ (compliance trail) │ │
│ └────────────────────┘ └──────────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Design Principles¶
-
Single Source of Truth:
ParticipantAnchorDate.enrollment_date_valueis the authoritative anchor;Participant.enrollment_dateis kept in sync for backward compatibility and performance. -
Immutable History: All anchor date changes create immutable
ParticipantAnchorDateHistoryrecords. No updates, only inserts. -
Explicit State Machine: Anchor dates progress through
unset → provisional → finalizedwith explicit override paths. -
Schedule Versioning: Changes to anchor date create new
ScheduleVersionrecords; old schedules are preserved as historical. -
Never Use Data Entry Timestamps: The service explicitly validates that source values are visit/event dates, never data entry timestamps.
2. Study-Level Configuration Schema¶
A. EnrollmentDatePolicy (Study Configuration)¶
/**
* Study-level configuration for enrollment date determination.
* Stored in Study.config.enrollment_date_policy (JSONB).
*/
interface EnrollmentDatePolicy {
// === Anchor Type ===
/**
* The semantic meaning of the anchor date.
* Controls which milestones are expected before anchor is considered "set".
*/
anchor_type:
| "enrollment" // Generic enrollment date (default)
| "consent" // Consent completion date
| "eligibility" // Eligibility confirmation date
| "randomization" // Randomization date
| "baseline_visit" // First study visit date
| "custom"; // Custom definition (see custom_anchor_definition)
// === Source Configuration ===
/**
* Ordered list of sources with precedence (first match wins).
* Each source has a type and optional mapping configuration.
*/
sources: AnchorDateSource[];
// === Prerequisites ===
/**
* Conditions that must be met before enrollment date can be finalized.
* If any prerequisite is not met, the date remains "provisional".
*/
prerequisites: {
require_consent_signed: boolean; // Must have signed ConsentInstance
require_eligibility_confirmed: boolean; // Must have confirmed EligibilityAssessment
require_randomization: boolean; // Must be randomized (for RCTs)
require_baseline_visit: boolean; // Must have completed baseline visit
custom_rule?: string; // JSONLogic expression (future)
};
// === Permissions ===
permissions: {
/**
* Roles that can manually set/override the enrollment date.
* Uses existing role system: admin, coordinator, investigator, data_manager
*/
can_set: string[]; // e.g., ["admin", "coordinator"]
can_override: string[]; // e.g., ["admin"]
/**
* Whether participant can self-declare (via patient portal).
* Only applicable if anchor_type allows it.
*/
participant_can_set: boolean;
/**
* Whether override requires documentation.
*/
override_requires_reason: boolean;
override_requires_approval: boolean; // Requires secondary approval
override_approver_roles?: string[]; // Roles that can approve overrides
};
// === Re-anchoring Policy ===
re_anchoring: {
/**
* Controls when changes to enrollment date are allowed.
*/
allow_after_scheduling: boolean; // After visits scheduled
allow_after_data_entered: boolean; // After any form data entered
allow_after_signature: boolean; // After any form signed
allow_after_lock: boolean; // After any form locked
/**
* Maximum allowed shift in days (0 = any shift allowed).
* Protects against accidental large changes.
*/
max_shift_days: number;
/**
* What happens when anchor changes after visits completed.
*/
completed_visit_handling:
| "preserve_original" // Keep original scheduled dates
| "recalculate_reporting" // Recalc for reporting but don't change
| "flag_for_review"; // Mark visits as "anchor_changed"
};
// === Multi-Consent Handling ===
multi_consent: {
/**
* Which consent should be used for anchor date.
*/
anchor_consent: "first" | "latest" | "specific_version";
specific_consent_version_id?: string; // If anchor_consent = "specific_version"
/**
* Whether re-consent can update the enrollment date.
*/
reconsent_updates_anchor: boolean;
};
// === Time Precision ===
time_precision: {
precision: "date" | "datetime"; // date = date-only, datetime = full timestamp
timezone_policy:
| "site_local" // Use site's timezone
| "study_timezone" // Use study's configured timezone
| "utc"; // Store as UTC
study_timezone?: string; // IANA timezone, e.g., "America/New_York"
};
// === Validation Rules ===
validation: {
/**
* Enrollment date cannot be before consent date.
*/
cannot_precede_consent: boolean;
/**
* Enrollment date cannot be in the future.
*/
cannot_be_future: boolean;
/**
* Enrollment date cannot be before study start date.
*/
cannot_precede_study_start: boolean;
/**
* Maximum days between consent and enrollment.
*/
max_days_from_consent?: number;
/**
* Custom validation rules (JSONLogic).
*/
custom_rules?: ValidationRule[];
};
// === Custom Anchor Definition ===
/**
* Only used when anchor_type = "custom".
*/
custom_anchor_definition?: {
name: string; // Display name, e.g., "Treatment Start Date"
description: string; // Explanation for users
semantic_type: string; // ODM OID for export, e.g., "SE.TXSTART"
};
}
/**
* A source from which enrollment date can be derived.
*/
interface AnchorDateSource {
/**
* Source type.
*/
type:
| "consent_workflow" // From ConsentInstance.consent_timestamp
| "eligibility_workflow" // From EligibilityAssessment.confirmed_at
| "randomization" // From Randomization.randomized_at
| "form_item" // From specific form field
| "computed_rule" // Computed from other fields
| "manual_entry"; // Coordinator enters manually
/**
* Priority (lower = higher priority). Used when multiple sources could apply.
*/
priority: number;
/**
* Form item mapping (only for type = "form_item").
*/
form_mapping?: {
form_oid: string; // StudyForm OID
item_oid: string; // Item OID within the form
/**
* How to handle partial dates (e.g., "2024-01" without day).
*/
partial_date_handling:
| "reject" // Validation error
| "first_of_month" // Use first day of month
| "last_of_month" // Use last day of month
| "middle_of_month"; // Use 15th
};
/**
* Computed rule (only for type = "computed_rule").
*/
computed_rule?: {
expression: string; // JSONLogic or simple expression
description: string; // Human-readable description
};
/**
* Whether this source is currently active.
*/
is_active: boolean;
}
/**
* Custom validation rule.
*/
interface ValidationRule {
id: string;
name: string;
expression: string; // JSONLogic expression
error_message: string;
severity: "error" | "warning";
}
B. Default Policy (MVP)¶
{
"anchor_type": "enrollment",
"sources": [
{
"type": "consent_workflow",
"priority": 1,
"is_active": true
},
{
"type": "manual_entry",
"priority": 2,
"is_active": true
}
],
"prerequisites": {
"require_consent_signed": true,
"require_eligibility_confirmed": false,
"require_randomization": false,
"require_baseline_visit": false
},
"permissions": {
"can_set": ["admin", "coordinator"],
"can_override": ["admin"],
"participant_can_set": false,
"override_requires_reason": true,
"override_requires_approval": false
},
"re_anchoring": {
"allow_after_scheduling": true,
"allow_after_data_entered": true,
"allow_after_signature": false,
"allow_after_lock": false,
"max_shift_days": 0,
"completed_visit_handling": "flag_for_review"
},
"multi_consent": {
"anchor_consent": "first",
"reconsent_updates_anchor": false
},
"time_precision": {
"precision": "date",
"timezone_policy": "site_local"
},
"validation": {
"cannot_precede_consent": true,
"cannot_be_future": true,
"cannot_precede_study_start": true
}
}
3. Data Model Proposal¶
A. Core Tables¶
# server/app/db/models.py (additions)
class ParticipantAnchorDate(Base):
"""
Current anchor date state for a participant.
This is the authoritative source for enrollment date.
Participant.enrollment_date is kept in sync for backward
compatibility and query performance.
"""
__tablename__ = "participant_anchor_dates"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
participant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.participants.id", ondelete="CASCADE"),
unique=True, # One active anchor per participant
nullable=False
)
study_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.studies.id", ondelete="CASCADE"),
nullable=False
)
# === Anchor Date Value ===
enrollment_date_value: Mapped[Optional[date]] = mapped_column(Date)
# The actual anchor date (date-only)
enrollment_datetime_value: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True)
)
# Full timestamp if time_precision = "datetime"
precision: Mapped[str] = mapped_column(
String(20), default="date"
)
# "date" | "datetime" | "partial_year" | "partial_month"
timezone: Mapped[Optional[str]] = mapped_column(String(50))
# IANA timezone of the source, e.g., "America/New_York"
# === Source Tracking ===
source_type: Mapped[str] = mapped_column(String(50), nullable=False)
# "consent_workflow" | "eligibility_workflow" | "form_item" |
# "computed_rule" | "manual_entry" | "override"
source_ref: Mapped[Optional[dict]] = mapped_column(JSONB)
# Structure depends on source_type:
# - consent_workflow: {"consent_instance_id": "uuid"}
# - eligibility_workflow: {"eligibility_assessment_id": "uuid"}
# - form_item: {"form_instance_id": "uuid", "item_oid": "string", "form_oid": "string"}
# - manual_entry: {"entered_by_user_id": "uuid"}
# - override: {"override_by_user_id": "uuid", "previous_source_type": "..."}
captured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# When this anchor date was captured
# === Status ===
status: Mapped[str] = mapped_column(String(20), default="unset")
# "unset" | "provisional" | "finalized"
# Unset: No anchor date yet
# Provisional: Anchor date set but prerequisites not met
# Finalized: Anchor date locked (prerequisites met)
finalized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
finalized_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.users.id")
)
# === Override Tracking ===
is_override: Mapped[bool] = mapped_column(Boolean, default=False)
override_reason: Mapped[Optional[str]] = mapped_column(Text)
override_actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.users.id")
)
override_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Override approval (if required by policy)
override_approved: Mapped[Optional[bool]] = mapped_column(Boolean)
override_approved_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.users.id")
)
override_approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# === Version Tracking ===
version: Mapped[int] = mapped_column(Integer, default=1)
# Incremented on each change
current_schedule_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.schedule_versions.id")
)
# Reference to schedule generated from this anchor
# === Timestamps ===
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# === Relationships ===
participant: Mapped["Participant"] = relationship(
back_populates="anchor_date"
)
study: Mapped["Study"] = relationship()
history: Mapped[list["ParticipantAnchorDateHistory"]] = relationship(
back_populates="anchor_date",
order_by="ParticipantAnchorDateHistory.created_at.desc()"
)
class ParticipantAnchorDateHistory(Base):
"""
Immutable audit ledger for anchor date changes.
Every change to ParticipantAnchorDate creates a new history record.
Records are never updated or deleted.
"""
__tablename__ = "participant_anchor_date_history"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
anchor_date_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.participant_anchor_dates.id", ondelete="CASCADE"),
nullable=False
)
participant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.participants.id", ondelete="CASCADE"),
nullable=False
)
study_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.studies.id", ondelete="CASCADE"),
nullable=False
)
# === Event Type ===
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
# "PROPOSED" | "SET" | "CHANGED" | "FINALIZED" |
# "OVERRIDE_REQUESTED" | "OVERRIDE_GRANTED" | "OVERRIDE_DENIED"
# === Snapshot at Time of Event ===
enrollment_date_value: Mapped[Optional[date]] = mapped_column(Date)
enrollment_datetime_value: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True)
)
precision: Mapped[str] = mapped_column(String(20))
timezone: Mapped[Optional[str]] = mapped_column(String(50))
status_before: Mapped[Optional[str]] = mapped_column(String(20))
status_after: Mapped[str] = mapped_column(String(20), nullable=False)
source_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_ref: Mapped[Optional[dict]] = mapped_column(JSONB)
# For CHANGED events: what was the previous value?
previous_enrollment_date: Mapped[Optional[date]] = mapped_column(Date)
previous_source_type: Mapped[Optional[str]] = mapped_column(String(50))
change_delta_days: Mapped[Optional[int]] = mapped_column(Integer)
# Positive = moved later, Negative = moved earlier
# === Actor Information ===
actor_user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.users.id")
)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
# "user" | "system" | "participant" | "workflow"
# === Context ===
reason: Mapped[Optional[str]] = mapped_column(Text)
# Required for overrides, optional for other changes
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
user_agent: Mapped[Optional[str]] = mapped_column(String(500))
request_id: Mapped[Optional[str]] = mapped_column(String(36))
# === Schedule Impact ===
schedule_version_created_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.schedule_versions.id")
)
# If this change triggered schedule regeneration
# === Timestamp (immutable) ===
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# === Relationships ===
anchor_date: Mapped["ParticipantAnchorDate"] = relationship(
back_populates="history"
)
__table_args__ = (
# Index for querying participant history
Index("ix_anchor_history_participant", "participant_id", "created_at"),
# Index for audit queries
Index("ix_anchor_history_study", "study_id", "created_at"),
)
class ScheduleVersion(Base):
"""
Versioned schedule for a participant.
Each anchor date change creates a new schedule version.
Old versions are preserved for audit and reporting.
"""
__tablename__ = "schedule_versions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
participant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.participants.id", ondelete="CASCADE"),
nullable=False
)
study_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.studies.id", ondelete="CASCADE"),
nullable=False
)
# === Version Tracking ===
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
# Sequential version number per participant
is_current: Mapped[bool] = mapped_column(Boolean, default=True)
# Only one version is current at a time
superseded_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.schedule_versions.id")
)
# Points to newer version if superseded
# === Anchor Reference ===
anchor_date_used: Mapped[date] = mapped_column(Date, nullable=False)
# The enrollment date used to generate this schedule
anchor_version: Mapped[int] = mapped_column(Integer, nullable=False)
# ParticipantAnchorDate.version at time of generation
anchor_date_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.participant_anchor_dates.id"),
nullable=False
)
# === Scheduling Context ===
scheduling_mode: Mapped[str] = mapped_column(String(20), nullable=False)
# "legacy" | "edc" (from UnifiedSchedulerService)
metadata_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.metadata_versions.id")
)
# For EDC mode: which metadata version was used
holiday_calendar_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.holiday_calendars.id")
)
# Holiday calendar used for business-day adjustments
scheduling_settings: Mapped[dict] = mapped_column(JSONB, default=dict)
# Snapshot of scheduling settings at generation time
# === Status ===
status: Mapped[str] = mapped_column(String(20), default="active")
# "active" | "superseded" | "reconciled"
# === Generation Details ===
visits_generated: Mapped[int] = mapped_column(Integer, default=0)
generation_warnings: Mapped[list] = mapped_column(JSONB, default=list)
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
generated_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.users.id")
)
superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
supersede_reason: Mapped[Optional[str]] = mapped_column(Text)
# === Timestamps ===
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# === Relationships ===
participant: Mapped["Participant"] = relationship()
scheduled_visits: Mapped[list["ScheduledVisit"]] = relationship(
back_populates="schedule_version"
)
study_event_instances: Mapped[list["StudyEventInstance"]] = relationship(
back_populates="schedule_version"
)
__table_args__ = (
# Only one current version per participant
Index(
"ix_schedule_version_current",
"participant_id",
unique=True,
postgresql_where=text("is_current = true")
),
)
class EligibilityAssessment(Base):
"""
Eligibility assessment record for a participant.
Tracks eligibility determination workflow and can serve
as an anchor date source.
"""
__tablename__ = "eligibility_assessments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
participant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.participants.id", ondelete="CASCADE"),
nullable=False
)
study_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("app.studies.id", ondelete="CASCADE"),
nullable=False
)
# === Assessment Status ===
status: Mapped[str] = mapped_column(String(20), default="pending")
# "pending" | "eligible" | "ineligible" | "deferred"
# === Eligibility Criteria Results ===
inclusion_criteria: Mapped[dict] = mapped_column(JSONB, default=dict)
# Structure: {"criterion_id": {"met": true, "value": "...", "notes": "..."}}
exclusion_criteria: Mapped[dict] = mapped_column(JSONB, default=dict)
# Structure: {"criterion_id": {"present": false, "value": "...", "notes": "..."}}
# === Confirmation ===
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# This is the eligibility confirmation date
confirmed_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.users.id")
)
# === Overrides / Waivers ===
has_waiver: Mapped[bool] = mapped_column(Boolean, default=False)
waiver_criteria_ids: Mapped[list] = mapped_column(JSONB, default=list)
waiver_reason: Mapped[Optional[str]] = mapped_column(Text)
waiver_approved_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("app.users.id")
)
waiver_approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# === Source Documents ===
source_documents: Mapped[list] = mapped_column(JSONB, default=list)
# Structure: [{"type": "lab_report", "ref": "...", "date": "..."}]
# === Timestamps ===
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# === Relationships ===
participant: Mapped["Participant"] = relationship()
# === Updates to Existing Models ===
# Add to Participant model:
# anchor_date: Mapped[Optional["ParticipantAnchorDate"]] = relationship(
# back_populates="participant", uselist=False
# )
# Add to ScheduledVisit model:
# schedule_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
# UUID(as_uuid=True), ForeignKey("app.schedule_versions.id")
# )
# schedule_version: Mapped[Optional["ScheduleVersion"]] = relationship(
# back_populates="scheduled_visits"
# )
# anchor_reconciled: Mapped[bool] = mapped_column(Boolean, default=False)
# anchor_reconciled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Add to StudyEventInstance model (similar):
# schedule_version_id, schedule_version, anchor_reconciled, anchor_reconciled_at
B. Indexes for Performance¶
-- Fast lookup of current anchor by participant
CREATE UNIQUE INDEX ix_anchor_participant
ON participant_anchor_dates(participant_id);
-- History queries by date range
CREATE INDEX ix_anchor_history_date_range
ON participant_anchor_date_history(study_id, created_at DESC);
-- Current schedule version lookup
CREATE UNIQUE INDEX ix_schedule_current
ON schedule_versions(participant_id)
WHERE is_current = true;
-- Visits by schedule version
CREATE INDEX ix_visits_schedule_version
ON scheduled_visits(schedule_version_id);
4. State Machine for Enrollment Date¶
A. State Diagram¶
┌────────────────┐
│ │
┌───────┤ UNSET │
│ │ │
│ └────────┬───────┘
│ │
│ [propose_from_source]
│ - consent signed
│ - form item entered
│ - manual entry
│ │
│ ▼
│ ┌────────────────┐
[override_back] │ │ │
with reason │ │ PROVISIONAL │◄────────────────┐
│ │ │ │
│ └────────┬───────┘ │
│ │ │
│ [prerequisites_met] │
│ - consent + eligibility confirmed │
│ - or per study policy │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ │ │
└───────┤ FINALIZED │─────────────────┘
│ │ [override_change]
└────────────────┘ with reason + approval
│ (if policy allows)
│
▼
┌────────────────┐
│ LOCKED │ (future: post-lock only)
│ (all signed) │
└────────────────┘
B. Transition Rules¶
| Current State | Event | Conditions | New State | Side Effects |
|---|---|---|---|---|
| UNSET | propose_enrollment_date | Valid source | PROVISIONAL | Create history entry, schedule visits if configured |
| UNSET | set_enrollment_date | Valid source + prerequisites met | FINALIZED | Create history, schedule visits |
| PROVISIONAL | prerequisites_met | All required conditions satisfied | FINALIZED | Create history, finalize schedule |
| PROVISIONAL | change_source | Different valid source | PROVISIONAL | Create history, regenerate schedule |
| PROVISIONAL | cancel | User cancels | UNSET | Create history, remove provisional schedule |
| FINALIZED | override_change | Allowed by policy + reason + (approval if required) | FINALIZED | Create history, regenerate schedule, mark old visits as reconciled |
| FINALIZED | override_back | Allowed by policy + reason | PROVISIONAL | Create history, regenerate schedule |
C. Audit Events¶
Each transition creates an immutable ParticipantAnchorDateHistory record with:
| Event Type | When | Required Fields |
|---|---|---|
ENROLLMENT_DATE_PROPOSED |
Date first set provisionally | source_type, source_ref, actor |
ENROLLMENT_DATE_SET |
Date finalized (prerequisites met) | source_type, source_ref, actor |
ENROLLMENT_DATE_CHANGED |
Value changed after finalization | previous_value, new_value, change_delta_days, reason |
ENROLLMENT_DATE_FINALIZED |
Moved from provisional to finalized | finalized_by, reason if manual |
ENROLLMENT_DATE_OVERRIDE_REQUESTED |
Override requested (if approval needed) | actor, reason |
ENROLLMENT_DATE_OVERRIDE_GRANTED |
Override approved | approver, reason |
ENROLLMENT_DATE_OVERRIDE_DENIED |
Override rejected | approver, denial_reason |
5. Enrollment Date Determination Algorithm¶
A. Pseudocode¶
async def evaluate_and_set_enrollment_date(
participant_id: UUID,
trigger_event: TriggerEvent, # consent_signed, eligibility_confirmed, form_saved, etc.
trigger_context: dict,
actor: User | None,
) -> EnrollmentDateResult:
"""
Main entry point for enrollment date evaluation.
Called when events occur that might set/change the enrollment date.
"""
# 1. Load participant and study policy
participant = await load_participant(participant_id)
policy = get_enrollment_date_policy(participant.study)
current_anchor = await get_current_anchor(participant_id)
# 2. Check if evaluation is needed
if current_anchor.status == "finalized" and not policy.re_anchoring.allow_after_finalization:
# Already finalized and changes not allowed
return EnrollmentDateResult(
action="none",
reason="Enrollment date already finalized"
)
# 3. Evaluate sources in priority order
candidate_date = None
winning_source = None
for source in sorted(policy.sources, key=lambda s: s.priority):
if not source.is_active:
continue
extracted = await extract_date_from_source(
participant=participant,
source=source,
trigger_event=trigger_event,
trigger_context=trigger_context,
)
if extracted.is_valid:
candidate_date = extracted.date_value
winning_source = source
break
# 4. If no candidate, return early
if candidate_date is None:
return EnrollmentDateResult(
action="none",
reason="No valid source provided a date"
)
# 5. Validate candidate date
validation_result = await validate_enrollment_date(
candidate_date=candidate_date,
participant=participant,
policy=policy,
current_anchor=current_anchor,
)
if not validation_result.is_valid:
return EnrollmentDateResult(
action="validation_failed",
errors=validation_result.errors,
warnings=validation_result.warnings,
)
# 6. Check prerequisites
prerequisites_met = await check_prerequisites(participant, policy)
# 7. Determine new status
if current_anchor.status == "unset":
if prerequisites_met:
new_status = "finalized"
event_type = "ENROLLMENT_DATE_SET"
else:
new_status = "provisional"
event_type = "ENROLLMENT_DATE_PROPOSED"
elif current_anchor.status == "provisional":
if candidate_date != current_anchor.enrollment_date_value:
event_type = "ENROLLMENT_DATE_CHANGED"
elif prerequisites_met:
new_status = "finalized"
event_type = "ENROLLMENT_DATE_FINALIZED"
else:
# Same date, still provisional, no change
return EnrollmentDateResult(action="none")
else: # finalized
if candidate_date != current_anchor.enrollment_date_value:
# This is a change to a finalized date - requires override
return EnrollmentDateResult(
action="override_required",
candidate_date=candidate_date,
source=winning_source,
)
else:
return EnrollmentDateResult(action="none", reason="Same date")
# 8. Apply the change
result = await apply_enrollment_date(
participant_id=participant_id,
new_date=candidate_date,
source=winning_source,
new_status=new_status,
event_type=event_type,
actor=actor,
trigger_context=trigger_context,
)
# 9. Trigger schedule (re)generation if needed
if new_status == "finalized" or policy.schedule_on_provisional:
schedule_result = await handle_schedule_generation(
participant_id=participant_id,
enrollment_date=candidate_date,
current_anchor=current_anchor,
new_anchor=result.anchor,
policy=policy,
)
result.schedule_result = schedule_result
return result
async def extract_date_from_source(
participant: Participant,
source: AnchorDateSource,
trigger_event: TriggerEvent,
trigger_context: dict,
) -> ExtractedDate:
"""
Extract date value from a specific source.
CRITICAL: Never use data entry timestamps.
"""
if source.type == "consent_workflow":
# Use consent_timestamp, NOT created_at or updated_at
consent = await get_active_consent(participant.id)
if consent and consent.status == "signed" and consent.consent_timestamp:
return ExtractedDate(
is_valid=True,
date_value=consent.consent_timestamp.date(),
source_ref={"consent_instance_id": str(consent.id)},
)
elif source.type == "eligibility_workflow":
# Use confirmed_at, NOT created_at
eligibility = await get_eligibility_assessment(participant.id)
if eligibility and eligibility.status == "eligible" and eligibility.confirmed_at:
return ExtractedDate(
is_valid=True,
date_value=eligibility.confirmed_at.date(),
source_ref={"eligibility_assessment_id": str(eligibility.id)},
)
elif source.type == "form_item":
# Extract from specific form field - use the DATE value, not entry timestamp
form_value = await get_form_item_value(
participant_id=participant.id,
form_oid=source.form_mapping.form_oid,
item_oid=source.form_mapping.item_oid,
)
if form_value and form_value.data_type == "date":
parsed = parse_date_with_precision(
form_value.value,
source.form_mapping.partial_date_handling,
)
if parsed.is_valid:
return ExtractedDate(
is_valid=True,
date_value=parsed.date,
precision=parsed.precision,
source_ref={
"form_instance_id": str(form_value.form_instance_id),
"form_oid": source.form_mapping.form_oid,
"item_oid": source.form_mapping.item_oid,
},
)
elif source.type == "manual_entry":
# Check if this trigger is a manual entry
if trigger_event == "manual_enrollment_date_entry":
return ExtractedDate(
is_valid=True,
date_value=trigger_context["date_value"],
source_ref={"entered_by_user_id": str(trigger_context["user_id"])},
)
return ExtractedDate(is_valid=False)
async def validate_enrollment_date(
candidate_date: date,
participant: Participant,
policy: EnrollmentDatePolicy,
current_anchor: ParticipantAnchorDate,
) -> ValidationResult:
"""
Validate candidate enrollment date against policy rules.
"""
errors = []
warnings = []
# Rule: Cannot be in the future
if policy.validation.cannot_be_future and candidate_date > date.today():
errors.append(ValidationError(
code="FUTURE_DATE",
message="Enrollment date cannot be in the future",
))
# Rule: Cannot precede consent date
if policy.validation.cannot_precede_consent:
consent = await get_active_consent(participant.id)
if consent and consent.consent_timestamp:
consent_date = consent.consent_timestamp.date()
if candidate_date < consent_date:
errors.append(ValidationError(
code="BEFORE_CONSENT",
message=f"Enrollment date cannot be before consent date ({consent_date})",
))
# Rule: Cannot precede study start date
if policy.validation.cannot_precede_study_start:
study = await get_study(participant.study_id)
if study.start_date and candidate_date < study.start_date:
errors.append(ValidationError(
code="BEFORE_STUDY_START",
message=f"Enrollment date cannot be before study start ({study.start_date})",
))
# Rule: Max days from consent
if policy.validation.max_days_from_consent:
consent = await get_active_consent(participant.id)
if consent and consent.consent_timestamp:
consent_date = consent.consent_timestamp.date()
delta = (candidate_date - consent_date).days
if delta > policy.validation.max_days_from_consent:
errors.append(ValidationError(
code="TOO_FAR_FROM_CONSENT",
message=f"Enrollment date is {delta} days from consent (max: {policy.validation.max_days_from_consent})",
))
# Rule: Max shift if already set
if current_anchor.enrollment_date_value and policy.re_anchoring.max_shift_days > 0:
delta = abs((candidate_date - current_anchor.enrollment_date_value).days)
if delta > policy.re_anchoring.max_shift_days:
warnings.append(ValidationWarning(
code="LARGE_SHIFT",
message=f"Date shift of {delta} days exceeds warning threshold ({policy.re_anchoring.max_shift_days})",
))
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings,
)
async def check_prerequisites(
participant: Participant,
policy: EnrollmentDatePolicy,
) -> bool:
"""
Check if all prerequisites are met for finalization.
"""
prereqs = policy.prerequisites
if prereqs.require_consent_signed:
consent = await get_active_consent(participant.id)
if not consent or consent.status != "signed":
return False
if prereqs.require_eligibility_confirmed:
eligibility = await get_eligibility_assessment(participant.id)
if not eligibility or eligibility.status != "eligible":
return False
if prereqs.require_randomization:
randomization = await get_randomization(participant.id)
if not randomization or not randomization.randomized_at:
return False
if prereqs.require_baseline_visit:
baseline = await get_baseline_visit(participant.id)
if not baseline or baseline.status != "completed":
return False
return True
6. Scheduling Integration¶
A. Sequence Diagram (Text-Based)¶
┌─────────┐ ┌──────────────────┐ ┌───────────────────┐ ┌─────────────────┐ ┌──────────┐
│ Trigger │ │ EnrollmentDate │ │ ScheduleVersioning│ │ UnifiedScheduler│ │ Database │
│ (Event) │ │ Service │ │ Service │ │ Service │ │ │
└────┬────┘ └────────┬─────────┘ └─────────┬─────────┘ └────────┬────────┘ └────┬─────┘
│ │ │ │ │
│ consent_signed │ │ │ │
│───────────────────────►│ │ │ │
│ │ │ │ │
│ │ evaluate_sources() │ │ │
│ │────────────────────────►│ │ │
│ │ │ │ │
│ │ candidate_date │ │ │
│ │◄────────────────────────│ │ │
│ │ │ │ │
│ │ validate_date() │ │ │
│ │───────────────────────┐ │ │ │
│ │ │ │ │ │
│ │◄──────────────────────┘ │ │ │
│ │ │ │ │
│ │ check_prerequisites() │ │ │
│ │───────────────────────┐ │ │ │
│ │ │ │ │ │
│ │◄──────────────────────┘ │ │ │
│ │ │ │ │
│ │ INSERT ParticipantAnchorDate │ │
│ │────────────────────────────────────────────────────────────────────►│
│ │ │ │ │
│ │ INSERT ParticipantAnchorDateHistory │ │
│ │────────────────────────────────────────────────────────────────────►│
│ │ │ │ │
│ │ UPDATE Participant.enrollment_date │ │
│ │────────────────────────────────────────────────────────────────────►│
│ │ │ │ │
│ │ create_schedule_version │ │ │
│ │────────────────────────►│ │ │
│ │ │ │ │
│ │ │ schedule_visits() │ │
│ │ │──────────────────────►│ │
│ │ │ │ │
│ │ │ │ INSERT visits │
│ │ │ │─────────────────►│
│ │ │ │ │
│ │ │ visit_ids │ │
│ │ │◄──────────────────────│ │
│ │ │ │ │
│ │ │ INSERT ScheduleVersion │
│ │ │─────────────────────────────────────────►│
│ │ │ │ │
│ │ schedule_result │ │ │
│ │◄────────────────────────│ │ │
│ │ │ │ │
│ EnrollmentDateResult │ │ │ │
│◄───────────────────────│ │ │ │
│ │ │ │ │
B. Re-Anchoring Sequence (Anchor Date Changed)¶
┌─────────┐ ┌──────────────────┐ ┌───────────────────┐ ┌──────────┐
│ Override│ │ EnrollmentDate │ │ ScheduleVersioning│ │ Database │
│ Request │ │ Service │ │ Service │ │ │
└────┬────┘ └────────┬─────────┘ └─────────┬─────────┘ └────┬─────┘
│ │ │ │
│ override_enrollment_date(new_date, reason) │ │
│───────────────────────►│ │ │
│ │ │ │
│ │ validate_override_allowed() │
│ │───────────────────────┐ │ │
│ │ │ │ │
│ │◄──────────────────────┘ │ │
│ │ │ │
│ │ check_completed_visits()│ │
│ │────────────────────────►│ │
│ │ │ │
│ │ completed_visit_count │ │
│ │◄────────────────────────│ │
│ │ │ │
│ │ UPDATE ParticipantAnchorDate (new value) │
│ │─────────────────────────────────────────────►│
│ │ │ │
│ │ INSERT ParticipantAnchorDateHistory (CHANGED)│
│ │─────────────────────────────────────────────►│
│ │ │ │
│ │ regenerate_schedule() │ │
│ │────────────────────────►│ │
│ │ │ │
│ │ │ 1. Mark old ScheduleVersion superseded │
│ │ │────────────────────────────────────────►│
│ │ │ │
│ │ │ 2. For completed visits: mark reconciled│
│ │ │────────────────────────────────────────►│
│ │ │ │
│ │ │ 3. For pending visits: recalculate dates│
│ │ │────────────────────────────────────────►│
│ │ │ │
│ │ │ 4. Create new ScheduleVersion │
│ │ │────────────────────────────────────────►│
│ │ │ │
│ │ │ 5. Link visits to new version │
│ │ │────────────────────────────────────────►│
│ │ │ │
│ OverrideResult (success, warnings) │ │
│◄───────────────────────│ │ │
│ │ │ │
C. Schedule Generation Rules¶
| Scenario | Action |
|---|---|
| No schedule exists | Generate schedule from anchor using UnifiedSchedulerService |
| Schedule exists, anchor unchanged | No action |
| Schedule exists, anchor changed | Create new ScheduleVersion, recalculate all target/scheduled dates |
| Visits completed before anchor change | Mark as anchor_reconciled=true, preserve original dates, recalculate for reporting only |
| Forms signed/locked after anchor change | Preserve signed forms' dates; only recalculate unsigned visits |
D. Reconciliation Strategy¶
When anchor changes after some visits are completed:
- Preserve Historical Data: Never delete or modify completed visit records
- Mark as Reconciled: Set
anchor_reconciled=trueandanchor_reconciled_aton completed visits - Recalculate Target Dates: For reporting, show what the target would have been under new anchor
- Show Banner: In UI, display "Anchor date changed on [date]. Visits before this date may have been scheduled under a different anchor."
- Compliance Reporting: Include both original and recalculated dates in exports
7. API Endpoints¶
A. Study Configuration Endpoints¶
# server/app/routers/enrollment_policy.py
@router.get("/studies/{study_id}/config/enrollment-policy")
async def get_enrollment_policy(
study_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> EnrollmentDatePolicyResponse:
"""
Get the enrollment date policy for a study.
Returns the default policy if none is configured.
"""
pass
@router.put("/studies/{study_id}/config/enrollment-policy")
async def update_enrollment_policy(
study_id: UUID,
policy: EnrollmentDatePolicyUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> EnrollmentDatePolicyResponse:
"""
Update the enrollment date policy for a study.
Requires admin or coordinator role.
"""
pass
@router.get("/studies/{study_id}/config/enrollment-policy/form-items")
async def get_available_form_items(
study_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[FormItemOption]:
"""
Get form items that can be used as enrollment date sources.
Filters to date-type items only.
"""
pass
B. Participant Anchor Date Endpoints¶
# server/app/routers/anchor_date.py
@router.get("/studies/{study_id}/participants/{participant_id}/anchor-date")
async def get_anchor_date(
study_id: UUID,
participant_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> AnchorDateResponse:
"""
Get the current anchor date for a participant.
"""
pass
@router.post("/studies/{study_id}/participants/{participant_id}/anchor-date")
async def set_anchor_date(
study_id: UUID,
participant_id: UUID,
request: SetAnchorDateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> AnchorDateResponse:
"""
Manually set the anchor date for a participant.
Request body:
{
"enrollment_date": "2024-01-15",
"source_type": "manual_entry",
"reason": "Optional reason for setting"
}
"""
pass
@router.post("/studies/{study_id}/participants/{participant_id}/anchor-date/override")
async def override_anchor_date(
study_id: UUID,
participant_id: UUID,
request: OverrideAnchorDateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> OverrideAnchorDateResponse:
"""
Override the anchor date (after finalization).
Requires appropriate permissions and reason.
Request body:
{
"new_enrollment_date": "2024-01-20",
"reason": "Required reason for override",
"regenerate_schedule": true
}
"""
pass
@router.get("/studies/{study_id}/participants/{participant_id}/anchor-date/history")
async def get_anchor_date_history(
study_id: UUID,
participant_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[AnchorDateHistoryEntry]:
"""
Get the complete history of anchor date changes.
"""
pass
@router.get("/studies/{study_id}/participants/{participant_id}/schedule-versions")
async def get_schedule_versions(
study_id: UUID,
participant_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[ScheduleVersionSummary]:
"""
Get all schedule versions for a participant.
"""
pass
C. Request/Response Schemas¶
# server/app/schemas/anchor_date.py
class SetAnchorDateRequest(BaseModel):
enrollment_date: date
source_type: Literal["manual_entry"] = "manual_entry"
reason: Optional[str] = None
class OverrideAnchorDateRequest(BaseModel):
new_enrollment_date: date
reason: str # Required for overrides
regenerate_schedule: bool = True
class AnchorDateResponse(BaseModel):
participant_id: UUID
enrollment_date: Optional[date]
enrollment_datetime: Optional[datetime]
precision: str
timezone: Optional[str]
status: Literal["unset", "provisional", "finalized"]
source_type: Optional[str]
source_ref: Optional[dict]
is_override: bool
override_reason: Optional[str]
version: int
current_schedule_version_id: Optional[UUID]
created_at: datetime
updated_at: datetime
class AnchorDateHistoryEntry(BaseModel):
id: UUID
event_type: str
enrollment_date_value: Optional[date]
status_before: Optional[str]
status_after: str
source_type: str
previous_enrollment_date: Optional[date]
change_delta_days: Optional[int]
actor_user_id: Optional[UUID]
actor_type: str
reason: Optional[str]
created_at: datetime
class ScheduleVersionSummary(BaseModel):
id: UUID
version_number: int
is_current: bool
anchor_date_used: date
scheduling_mode: str
status: str
visits_generated: int
generated_at: datetime
8. UI Plan¶
A. Study Setup: Enrollment Date Policy Page¶
Location: /studies/{study_id}/setup/enrollment-policy
┌─────────────────────────────────────────────────────────────────────────────┐
│ Study Setup > Enrollment Date Policy │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ ANCHOR TYPE │ │
│ │ │ │
│ │ ◉ Enrollment Date (default) │ │
│ │ ○ Consent Date │ │
│ │ ○ Eligibility Confirmation Date │ │
│ │ ○ Randomization Date │ │
│ │ ○ Baseline Visit Date │ │
│ │ ○ Custom (define below) │ │
│ │ └── Name: [Treatment Start Date______] │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ DATE SOURCES (drag to reorder priority) │ │
│ │ │ │
│ │ ⋮⋮ 1. Consent Workflow ✓ Active [Configure] │ │
│ │ ⋮⋮ 2. Form Item ✓ Active [Select Form/Item] │ │
│ │ └── Form: [Enrollment Form ▾] Item: [Enrollment Date ▾] │ │
│ │ ⋮⋮ 3. Manual Entry ✓ Active │ │
│ │ │ │
│ │ [+ Add Source] │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ PREREQUISITES (when is enrollment considered "finalized"?) │ │
│ │ │ │
│ │ ☑ Consent must be signed │ │
│ │ ☐ Eligibility must be confirmed │ │
│ │ ☐ Randomization must be complete │ │
│ │ ☐ Baseline visit must be completed │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ PERMISSIONS │ │
│ │ │ │
│ │ Can set enrollment date: [Admin, Coordinator ▾] │ │
│ │ Can override after finalized: [Admin ▾] │ │
│ │ │ │
│ │ ☑ Require reason for override │ │
│ │ ☐ Require secondary approval for override │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ CHANGE CONTROL │ │
│ │ │ │
│ │ Allow date changes: │ │
│ │ ☑ After schedule generated │ │
│ │ ☐ After any data entered │ │
│ │ ☐ After any form signed │ │
│ │ ☐ After any form locked │ │
│ │ │ │
│ │ Maximum shift warning: [30] days │ │
│ │ │ │
│ │ When anchor changes after visits completed: │ │
│ │ ◉ Flag visits for review (recommended) │ │
│ │ ○ Preserve original dates only │ │
│ │ ○ Recalculate for reporting only │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ VALIDATION RULES │ │
│ │ │ │
│ │ ☑ Enrollment date cannot be in the future │ │
│ │ ☑ Enrollment date cannot be before consent date │ │
│ │ ☑ Enrollment date cannot be before study start date │ │
│ │ ☐ Maximum days from consent: [___] days │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [Cancel] [Save Policy] │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
B. Participant View: Enrollment Panel¶
Location: Within participant detail page
┌─────────────────────────────────────────────────────────────────────────────┐
│ Enrollment Date [Edit ▾] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ ┌────────────────────────────────────┐ │
│ │ CURRENT VALUE │ │ STATUS │ │
│ │ │ │ │ │
│ │ January 15, 2024 │ │ ● Finalized │ │
│ │ │ │ All prerequisites met │ │
│ └─────────────────────────────────┘ └────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ SOURCE │ │
│ │ │ │
│ │ Derived from: Consent Workflow │ │
│ │ Consent signed: January 15, 2024 at 2:30 PM │ │
│ │ Version: 2 │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ SCHEDULE │ │
│ │ │ │
│ │ Schedule Version: 2 (current) │ │
│ │ Generated: January 15, 2024 at 2:35 PM │ │
│ │ Visits scheduled: 12 │ │
│ │ │ │
│ │ [View Schedule History] │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ HISTORY [View All] │ │
│ │ │ │
│ │ Jan 15, 2024 FINALIZED Consent + eligibility confirmed │ │
│ │ 2:35 PM By: System │ │
│ │ │ │
│ │ Jan 15, 2024 PROPOSED From consent workflow │ │
│ │ 2:30 PM By: System │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
C. Override Dialog¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ Override Enrollment Date [X] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ⚠ This participant's enrollment date has been finalized. Changing it │
│ will regenerate the visit schedule and mark completed visits as │
│ reconciled. │
│ │
│ Current enrollment date: January 15, 2024 │
│ Current schedule version: 2 (12 visits, 3 completed) │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ New enrollment date: [January 20, 2024___] 📅 │ │
│ │ │ │
│ │ Change: +5 days │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ Reason for override: * │ │
│ │ ┌───────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Enrollment date was entered incorrectly. Verified correct date │ │ │
│ │ │ from source documents (medical record #12345). │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ IMPACT PREVIEW │ │
│ │ │ │
│ │ • 3 completed visits will be marked as "reconciled" │ │
│ │ • 9 pending visits will be rescheduled │ │
│ │ • New schedule version (v3) will be created │ │
│ │ • Audit record will be created │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [Cancel] [Override Enrollment Date] │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
D. Anchor Changed Banner (on Schedule View)¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ ⚠ ANCHOR DATE CHANGED [Dismiss] │
│ │
│ The enrollment date was changed from January 15, 2024 to January 20, 2024 │
│ on January 22, 2024 by Dr. Smith. │
│ │
│ Reason: "Enrollment date was entered incorrectly..." │
│ │
│ Visits marked with ⟳ were completed under the previous anchor and have │
│ been reconciled. │
│ │
│ [View History] [View Previous Schedule] │
└─────────────────────────────────────────────────────────────────────────────┘
9. ODM and Export Considerations¶
A. ODM Representation¶
The enrollment date should be exported in ODM-XML in two locations:
1. Subject-Level Attribute
<SubjectData SubjectKey="P001">
<EnrollmentDate>2024-01-15</EnrollmentDate>
<!-- or as a clinical data item -->
</SubjectData>
2. Dedicated Anchor Date Form (Preferred for Interoperability)
<StudyEventData StudyEventOID="SE.ENROLLMENT">
<FormData FormOID="F.ENROLLMENT">
<ItemGroupData ItemGroupOID="IG.ENROLLMENT">
<ItemData ItemOID="I.ENRLDT" Value="2024-01-15"/>
<ItemData ItemOID="I.ENRLDT.SRC" Value="consent_workflow"/>
<ItemData ItemOID="I.ENRLDT.STATUS" Value="finalized"/>
</ItemGroupData>
</FormData>
</StudyEventData>
B. OID Strategy¶
| Element | OID Pattern | Example |
|---|---|---|
| Study Event (Enrollment) | SE.ENROLLMENT |
SE.ENROLLMENT |
| Form (Enrollment) | F.ENROLLMENT |
F.ENROLLMENT |
| Item Group | IG.ENROLLMENT |
IG.ENROLLMENT |
| Enrollment Date Item | I.ENRLDT |
I.ENRLDT |
| Source Type Item | I.ENRLDT.SRC |
I.ENRLDT.SRC |
| Status Item | I.ENRLDT.STATUS |
I.ENRLDT.STATUS |
C. Export Format¶
{
"participant_id": "P001",
"enrollment_data": {
"enrollment_date": "2024-01-15",
"enrollment_datetime": "2024-01-15T14:30:00Z",
"precision": "date",
"timezone": "America/New_York",
"status": "finalized",
"source_type": "consent_workflow",
"source_ref": {
"consent_instance_id": "uuid-here"
},
"version": 2,
"finalized_at": "2024-01-15T14:35:00Z"
},
"schedule_version": {
"version_number": 2,
"anchor_date_used": "2024-01-15",
"generated_at": "2024-01-15T14:35:00Z"
},
"anchor_history": [
{
"event_type": "ENROLLMENT_DATE_FINALIZED",
"enrollment_date": "2024-01-15",
"status_after": "finalized",
"created_at": "2024-01-15T14:35:00Z"
},
{
"event_type": "ENROLLMENT_DATE_PROPOSED",
"enrollment_date": "2024-01-15",
"status_after": "provisional",
"created_at": "2024-01-15T14:30:00Z"
}
]
}
10. Acceptance Tests¶
A. Core Functionality Tests¶
| ID | Test Case | Expected Behavior |
|---|---|---|
| T01 | Consent signed → enrollment date proposed | ParticipantAnchorDate created with status=provisional, history record created |
| T02 | Consent + eligibility confirmed → enrollment date finalized | Status changes to finalized, schedule generated |
| T03 | Manual enrollment date entry | Date set with source_type=manual_entry, history recorded |
| T04 | Form item as source | Date extracted from specified form/item, not data entry timestamp |
| T05 | Source priority ordering | Higher priority source wins when multiple sources provide values |
| T06 | Prerequisites not met | Status remains provisional even with date set |
| T07 | Prerequisites met after date set | Status transitions to finalized automatically |
B. Validation Tests¶
| ID | Test Case | Expected Behavior |
|---|---|---|
| T10 | Enrollment date in future | Validation error, date not accepted |
| T11 | Enrollment date before consent | Validation error when policy requires |
| T12 | Enrollment date before study start | Validation error when policy requires |
| T13 | Large date shift warning | Warning issued but change allowed |
| T14 | Partial date handling | Date resolved according to policy (first/last/middle of month) |
C. Override Tests¶
| ID | Test Case | Expected Behavior |
|---|---|---|
| T20 | Override finalized date with reason | Date changed, history with OVERRIDE event, schedule regenerated |
| T21 | Override without reason (when required) | Validation error, change rejected |
| T22 | Override by non-permitted user | Authorization error |
| T23 | Override after forms signed (when policy disallows) | Validation error |
| T24 | Override with approval workflow | Pending approval status, then OVERRIDE_GRANTED/DENIED |
D. Schedule Integration Tests¶
| ID | Test Case | Expected Behavior |
|---|---|---|
| T30 | First enrollment date set → schedule generated | ScheduleVersion v1 created, visits scheduled |
| T31 | Enrollment date changed → schedule regenerated | ScheduleVersion v2 created, v1 marked superseded |
| T32 | Completed visits during re-anchor | Completed visits marked as reconciled, dates preserved |
| T33 | Business day adjustments applied | Target dates adjusted per holiday calendar |
| T34 | Schedule history preserved | All versions queryable, old visits not deleted |
E. Multi-Consent Tests¶
| ID | Test Case | Expected Behavior |
|---|---|---|
| T40 | Re-consent (anchor_consent=first) | Enrollment date unchanged |
| T41 | Re-consent (anchor_consent=latest) | Enrollment date updated to new consent date |
| T42 | Re-consent with reconsent_updates_anchor=false | Enrollment date unchanged regardless |
F. Edge Cases¶
| ID | Test Case | Expected Behavior |
|---|---|---|
| T50 | Enrollment date set out of order (eligibility before consent) | Provisional until consent signed |
| T51 | Changing unrelated form fields | Enrollment date unchanged |
| T52 | Concurrent enrollment date proposals | Last-write-wins with audit trail |
| T53 | Participant withdrawn after enrollment | Enrollment date preserved, schedule frozen |
| T54 | Study timezone vs local timezone | Dates normalized according to policy |
11. MVP vs Future Enhancements¶
MVP Scope¶
- Form-derived enrollment date: Extract from specific form/item with OID picker
- Consent-derived enrollment date: Use
consent_timestampfrom ConsentInstance - Manual override with reason: Admin can override finalized date with audit
- Schedule regeneration with versioning: New
ScheduleVersionon anchor change - Immutable audit trail: All changes logged to history table
- Basic policy configuration: anchor_type, sources, prerequisites, permissions
- UI components: Policy config page, participant enrollment panel, override dialog
Future Enhancements (Late-Phase Trials)¶
| Enhancement | Description | Priority |
|---|---|---|
| E-signature coupling | Require e-signature for enrollment date finalization | High |
| Strict change control | Lock changes after signature/lock based on 21 CFR Part 11 | High |
| Define-XML mapping hooks | Auto-generate Define-XML metadata for anchor date | Medium |
| Computed rules (JSONLogic) | Complex enrollment date derivation rules | Medium |
| Approval workflow | Secondary approval for overrides | Medium |
| Multi-anchor support | Multiple anchor dates per participant (e.g., treatment start) | Low |
| Participant self-declaration | Allow patient-facing enrollment date entry | Low |
| Integration with central randomization | Sync anchor from IWRS/IXRS systems | Low |
12. Implementation Order¶
Phase 1: Core Models and Service (Week 1-2)¶
- Add
ParticipantAnchorDate,ParticipantAnchorDateHistory,ScheduleVersionmodels - Add
EligibilityAssessmentmodel (if not present) - Create Alembic migration
- Implement
EnrollmentDateServicewith evaluate/propose/finalize logic - Implement
ScheduleVersioningService - Add integration with
UnifiedSchedulerService
Phase 2: API and Validation (Week 2-3)¶
- Add API endpoints for policy configuration
- Add API endpoints for participant anchor date management
- Implement validation rules
- Add permission checks
- Integrate with existing consent workflow to trigger evaluation
Phase 3: UI Components (Week 3-4)¶
- Build Enrollment Date Policy page
- Build Participant Enrollment Panel component
- Build Override dialog
- Build Anchor Changed banner
- Update participant list to show enrollment status
Phase 4: Testing and Documentation (Week 4)¶
- Unit tests for services
- Integration tests for API
- E2E tests for UI flows
- API documentation
- User guide updates
Appendix A: TypeScript Interfaces (Portal)¶
// portal/src/types/anchor-date.ts
export interface EnrollmentDatePolicy {
anchor_type: 'enrollment' | 'consent' | 'eligibility' | 'randomization' | 'baseline_visit' | 'custom';
sources: AnchorDateSource[];
prerequisites: {
require_consent_signed: boolean;
require_eligibility_confirmed: boolean;
require_randomization: boolean;
require_baseline_visit: boolean;
};
permissions: {
can_set: string[];
can_override: string[];
participant_can_set: boolean;
override_requires_reason: boolean;
override_requires_approval: boolean;
override_approver_roles?: string[];
};
re_anchoring: {
allow_after_scheduling: boolean;
allow_after_data_entered: boolean;
allow_after_signature: boolean;
allow_after_lock: boolean;
max_shift_days: number;
completed_visit_handling: 'preserve_original' | 'recalculate_reporting' | 'flag_for_review';
};
multi_consent: {
anchor_consent: 'first' | 'latest' | 'specific_version';
specific_consent_version_id?: string;
reconsent_updates_anchor: boolean;
};
time_precision: {
precision: 'date' | 'datetime';
timezone_policy: 'site_local' | 'study_timezone' | 'utc';
study_timezone?: string;
};
validation: {
cannot_precede_consent: boolean;
cannot_be_future: boolean;
cannot_precede_study_start: boolean;
max_days_from_consent?: number;
};
custom_anchor_definition?: {
name: string;
description: string;
semantic_type: string;
};
}
export interface AnchorDateSource {
type: 'consent_workflow' | 'eligibility_workflow' | 'randomization' | 'form_item' | 'computed_rule' | 'manual_entry';
priority: number;
form_mapping?: {
form_oid: string;
item_oid: string;
partial_date_handling: 'reject' | 'first_of_month' | 'last_of_month' | 'middle_of_month';
};
computed_rule?: {
expression: string;
description: string;
};
is_active: boolean;
}
export interface ParticipantAnchorDate {
participant_id: string;
enrollment_date: string | null;
enrollment_datetime: string | null;
precision: 'date' | 'datetime' | 'partial_year' | 'partial_month';
timezone: string | null;
status: 'unset' | 'provisional' | 'finalized';
source_type: string | null;
source_ref: Record<string, unknown> | null;
is_override: boolean;
override_reason: string | null;
version: number;
current_schedule_version_id: string | null;
created_at: string;
updated_at: string;
}
export interface AnchorDateHistoryEntry {
id: string;
event_type: 'PROPOSED' | 'SET' | 'CHANGED' | 'FINALIZED' | 'OVERRIDE_REQUESTED' | 'OVERRIDE_GRANTED' | 'OVERRIDE_DENIED';
enrollment_date_value: string | null;
status_before: string | null;
status_after: string;
source_type: string;
previous_enrollment_date: string | null;
change_delta_days: number | null;
actor_user_id: string | null;
actor_type: 'user' | 'system' | 'participant' | 'workflow';
reason: string | null;
created_at: string;
}
export interface ScheduleVersion {
id: string;
version_number: number;
is_current: boolean;
anchor_date_used: string;
scheduling_mode: 'legacy' | 'edc';
status: 'active' | 'superseded' | 'reconciled';
visits_generated: number;
generation_warnings: string[];
generated_at: string;
superseded_at: string | null;
supersede_reason: string | null;
}
export interface SetAnchorDateRequest {
enrollment_date: string;
source_type?: 'manual_entry';
reason?: string;
}
export interface OverrideAnchorDateRequest {
new_enrollment_date: string;
reason: string;
regenerate_schedule?: boolean;
}
export interface OverrideImpactPreview {
completed_visits_to_reconcile: number;
pending_visits_to_reschedule: number;
new_schedule_version: number;
warnings: string[];
}
Appendix B: Migration Script¶
"""Add enrollment date service models
Revision ID: xxxx
Revises: yyyy
Create Date: 2024-01-xx
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
def upgrade():
# ParticipantAnchorDate
op.create_table(
'participant_anchor_dates',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('participant_id', UUID(as_uuid=True), sa.ForeignKey('app.participants.id', ondelete='CASCADE'), unique=True, nullable=False),
sa.Column('study_id', UUID(as_uuid=True), sa.ForeignKey('app.studies.id', ondelete='CASCADE'), nullable=False),
sa.Column('enrollment_date_value', sa.Date),
sa.Column('enrollment_datetime_value', sa.DateTime(timezone=True)),
sa.Column('precision', sa.String(20), default='date'),
sa.Column('timezone', sa.String(50)),
sa.Column('source_type', sa.String(50), nullable=False),
sa.Column('source_ref', JSONB),
sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('status', sa.String(20), default='unset'),
sa.Column('finalized_at', sa.DateTime(timezone=True)),
sa.Column('finalized_by_id', UUID(as_uuid=True), sa.ForeignKey('app.users.id')),
sa.Column('is_override', sa.Boolean, default=False),
sa.Column('override_reason', sa.Text),
sa.Column('override_actor_id', UUID(as_uuid=True), sa.ForeignKey('app.users.id')),
sa.Column('override_at', sa.DateTime(timezone=True)),
sa.Column('override_approved', sa.Boolean),
sa.Column('override_approved_by_id', UUID(as_uuid=True), sa.ForeignKey('app.users.id')),
sa.Column('override_approved_at', sa.DateTime(timezone=True)),
sa.Column('version', sa.Integer, default=1),
sa.Column('current_schedule_version_id', UUID(as_uuid=True)), # FK added after ScheduleVersion created
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
schema='app'
)
# ParticipantAnchorDateHistory
op.create_table(
'participant_anchor_date_history',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('anchor_date_id', UUID(as_uuid=True), sa.ForeignKey('app.participant_anchor_dates.id', ondelete='CASCADE'), nullable=False),
sa.Column('participant_id', UUID(as_uuid=True), sa.ForeignKey('app.participants.id', ondelete='CASCADE'), nullable=False),
sa.Column('study_id', UUID(as_uuid=True), sa.ForeignKey('app.studies.id', ondelete='CASCADE'), nullable=False),
sa.Column('event_type', sa.String(50), nullable=False),
sa.Column('enrollment_date_value', sa.Date),
sa.Column('enrollment_datetime_value', sa.DateTime(timezone=True)),
sa.Column('precision', sa.String(20)),
sa.Column('timezone', sa.String(50)),
sa.Column('status_before', sa.String(20)),
sa.Column('status_after', sa.String(20), nullable=False),
sa.Column('source_type', sa.String(50), nullable=False),
sa.Column('source_ref', JSONB),
sa.Column('previous_enrollment_date', sa.Date),
sa.Column('previous_source_type', sa.String(50)),
sa.Column('change_delta_days', sa.Integer),
sa.Column('actor_user_id', UUID(as_uuid=True), sa.ForeignKey('app.users.id')),
sa.Column('actor_type', sa.String(20), nullable=False),
sa.Column('reason', sa.Text),
sa.Column('ip_address', sa.String(45)),
sa.Column('user_agent', sa.String(500)),
sa.Column('request_id', sa.String(36)),
sa.Column('schedule_version_created_id', UUID(as_uuid=True)),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
schema='app'
)
# ScheduleVersion
op.create_table(
'schedule_versions',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('participant_id', UUID(as_uuid=True), sa.ForeignKey('app.participants.id', ondelete='CASCADE'), nullable=False),
sa.Column('study_id', UUID(as_uuid=True), sa.ForeignKey('app.studies.id', ondelete='CASCADE'), nullable=False),
sa.Column('version_number', sa.Integer, nullable=False),
sa.Column('is_current', sa.Boolean, default=True),
sa.Column('superseded_by_id', UUID(as_uuid=True)), # Self-reference
sa.Column('anchor_date_used', sa.Date, nullable=False),
sa.Column('anchor_version', sa.Integer, nullable=False),
sa.Column('anchor_date_id', UUID(as_uuid=True), sa.ForeignKey('app.participant_anchor_dates.id'), nullable=False),
sa.Column('scheduling_mode', sa.String(20), nullable=False),
sa.Column('metadata_version_id', UUID(as_uuid=True), sa.ForeignKey('app.metadata_versions.id')),
sa.Column('holiday_calendar_id', UUID(as_uuid=True), sa.ForeignKey('app.holiday_calendars.id')),
sa.Column('scheduling_settings', JSONB, default=dict),
sa.Column('status', sa.String(20), default='active'),
sa.Column('visits_generated', sa.Integer, default=0),
sa.Column('generation_warnings', JSONB, default=list),
sa.Column('generated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('generated_by_id', UUID(as_uuid=True), sa.ForeignKey('app.users.id')),
sa.Column('superseded_at', sa.DateTime(timezone=True)),
sa.Column('supersede_reason', sa.Text),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
schema='app'
)
# Add self-reference FK
op.create_foreign_key(
'fk_schedule_version_superseded',
'schedule_versions',
'schedule_versions',
['superseded_by_id'],
['id'],
source_schema='app',
referent_schema='app'
)
# Add FK from anchor to schedule version
op.create_foreign_key(
'fk_anchor_schedule_version',
'participant_anchor_dates',
'schedule_versions',
['current_schedule_version_id'],
['id'],
source_schema='app',
referent_schema='app'
)
# Add schedule_version_id to scheduled_visits
op.add_column(
'scheduled_visits',
sa.Column('schedule_version_id', UUID(as_uuid=True), sa.ForeignKey('app.schedule_versions.id')),
schema='app'
)
op.add_column(
'scheduled_visits',
sa.Column('anchor_reconciled', sa.Boolean, default=False),
schema='app'
)
op.add_column(
'scheduled_visits',
sa.Column('anchor_reconciled_at', sa.DateTime(timezone=True)),
schema='app'
)
# Add schedule_version_id to study_event_instances
op.add_column(
'study_event_instances',
sa.Column('schedule_version_id', UUID(as_uuid=True), sa.ForeignKey('app.schedule_versions.id')),
schema='app'
)
op.add_column(
'study_event_instances',
sa.Column('anchor_reconciled', sa.Boolean, default=False),
schema='app'
)
op.add_column(
'study_event_instances',
sa.Column('anchor_reconciled_at', sa.DateTime(timezone=True)),
schema='app'
)
# EligibilityAssessment (if not exists)
op.create_table(
'eligibility_assessments',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('participant_id', UUID(as_uuid=True), sa.ForeignKey('app.participants.id', ondelete='CASCADE'), nullable=False),
sa.Column('study_id', UUID(as_uuid=True), sa.ForeignKey('app.studies.id', ondelete='CASCADE'), nullable=False),
sa.Column('status', sa.String(20), default='pending'),
sa.Column('inclusion_criteria', JSONB, default=dict),
sa.Column('exclusion_criteria', JSONB, default=dict),
sa.Column('confirmed_at', sa.DateTime(timezone=True)),
sa.Column('confirmed_by_id', UUID(as_uuid=True), sa.ForeignKey('app.users.id')),
sa.Column('has_waiver', sa.Boolean, default=False),
sa.Column('waiver_criteria_ids', JSONB, default=list),
sa.Column('waiver_reason', sa.Text),
sa.Column('waiver_approved_by_id', UUID(as_uuid=True), sa.ForeignKey('app.users.id')),
sa.Column('waiver_approved_at', sa.DateTime(timezone=True)),
sa.Column('source_documents', JSONB, default=list),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
schema='app'
)
# Indexes
op.create_index('ix_anchor_history_participant', 'participant_anchor_date_history', ['participant_id', 'created_at'], schema='app')
op.create_index('ix_anchor_history_study', 'participant_anchor_date_history', ['study_id', 'created_at'], schema='app')
op.create_index('ix_visits_schedule_version', 'scheduled_visits', ['schedule_version_id'], schema='app')
op.create_index('ix_events_schedule_version', 'study_event_instances', ['schedule_version_id'], schema='app')
# Partial unique index for current schedule version
op.execute("""
CREATE UNIQUE INDEX ix_schedule_version_current
ON app.schedule_versions (participant_id)
WHERE is_current = true
""")
def downgrade():
op.execute("DROP INDEX IF EXISTS app.ix_schedule_version_current")
op.drop_index('ix_events_schedule_version', 'study_event_instances', schema='app')
op.drop_index('ix_visits_schedule_version', 'scheduled_visits', schema='app')
op.drop_index('ix_anchor_history_study', 'participant_anchor_date_history', schema='app')
op.drop_index('ix_anchor_history_participant', 'participant_anchor_date_history', schema='app')
op.drop_column('study_event_instances', 'anchor_reconciled_at', schema='app')
op.drop_column('study_event_instances', 'anchor_reconciled', schema='app')
op.drop_column('study_event_instances', 'schedule_version_id', schema='app')
op.drop_column('scheduled_visits', 'anchor_reconciled_at', schema='app')
op.drop_column('scheduled_visits', 'anchor_reconciled', schema='app')
op.drop_column('scheduled_visits', 'schedule_version_id', schema='app')
op.drop_table('eligibility_assessments', schema='app')
op.drop_table('schedule_versions', schema='app')
op.drop_table('participant_anchor_date_history', schema='app')
op.drop_table('participant_anchor_dates', schema='app')
End of Design Document