Skip to content

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

  1. Single Source of Truth: ParticipantAnchorDate.enrollment_date_value is the authoritative anchor; Participant.enrollment_date is kept in sync for backward compatibility and performance.

  2. Immutable History: All anchor date changes create immutable ParticipantAnchorDateHistory records. No updates, only inserts.

  3. Explicit State Machine: Anchor dates progress through unset → provisional → finalized with explicit override paths.

  4. Schedule Versioning: Changes to anchor date create new ScheduleVersion records; old schedules are preserved as historical.

  5. 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:

  1. Preserve Historical Data: Never delete or modify completed visit records
  2. Mark as Reconciled: Set anchor_reconciled=true and anchor_reconciled_at on completed visits
  3. Recalculate Target Dates: For reporting, show what the target would have been under new anchor
  4. Show Banner: In UI, display "Anchor date changed on [date]. Visits before this date may have been scheduled under a different anchor."
  5. 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
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

  1. Form-derived enrollment date: Extract from specific form/item with OID picker
  2. Consent-derived enrollment date: Use consent_timestamp from ConsentInstance
  3. Manual override with reason: Admin can override finalized date with audit
  4. Schedule regeneration with versioning: New ScheduleVersion on anchor change
  5. Immutable audit trail: All changes logged to history table
  6. Basic policy configuration: anchor_type, sources, prerequisites, permissions
  7. 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)

  1. Add ParticipantAnchorDate, ParticipantAnchorDateHistory, ScheduleVersion models
  2. Add EligibilityAssessment model (if not present)
  3. Create Alembic migration
  4. Implement EnrollmentDateService with evaluate/propose/finalize logic
  5. Implement ScheduleVersioningService
  6. Add integration with UnifiedSchedulerService

Phase 2: API and Validation (Week 2-3)

  1. Add API endpoints for policy configuration
  2. Add API endpoints for participant anchor date management
  3. Implement validation rules
  4. Add permission checks
  5. Integrate with existing consent workflow to trigger evaluation

Phase 3: UI Components (Week 3-4)

  1. Build Enrollment Date Policy page
  2. Build Participant Enrollment Panel component
  3. Build Override dialog
  4. Build Anchor Changed banner
  5. Update participant list to show enrollment status

Phase 4: Testing and Documentation (Week 4)

  1. Unit tests for services
  2. Integration tests for API
  3. E2E tests for UI flows
  4. API documentation
  5. 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