Deterministic Query Routing Workflows for CRAs in Clinical EDC Sync Pipelines

Query routing is the stage of an EDC sync pipeline that decides who must act on a discrepancy and by when — directing every raised query to the right Clinical Research Associate (CRA), site coordinator, or medical monitor along a predefined, auditable path. This page is a sub-discipline of the broader Clinical Query Generation & Discrepancy Management section: it consumes the structured queries produced by Automated Clinical Query Generation and turns them into owned, time-boxed work items. For clinical data managers and Python ETL engineers, the engineering problem is to replace manual triage spreadsheets with a deterministic, version-controlled state machine that routes consistently across study sites, data domains, and regional CROs while preserving a complete, inspection-ready audit trail under 21 CFR Part 11 and ALCOA+. Get routing wrong and queries age silently, SLAs breach unnoticed, and the same discrepancy lands in two reviewers’ queues at once.

Query Lifecycle State Machine

Routing is governed by a finite state machine with monotonic transitions: queries advance from open to closed, can reopen on an insufficient response, and escalate to a medical monitor on SLA breach. Each edge is a logged, attributable event — never an in-place mutation.

Query lifecycle state machine with reopen and SLA escalation Monotonic state transitions for a routed query. The main left-to-right path is OPEN, ASSIGNED, PENDING SITE, RESOLVED, CLOSED. Two feedback edges return to ASSIGNED: a reopen edge from RESOLVED when the site response is insufficient, and a reassign edge from ESCALATED after a medical monitor picks up an SLA-breached query. ASSIGNED escalates downward to ESCALATED on SLA breach. Each edge is an append-only, attributable event, never an in-place update. CLOSED is the terminal state. OPEN ASSIGNED PENDING SITE RESOLVED CLOSED ESCALATED route by severity + owner query sent to site site response CRA verifies response insufficient · reopen SLA breach monitor reassigns start end

Concept and Prerequisites

Routing sits downstream of generation and validation and upstream of resolution. It consumes an immutable query record — already bound to a rule_id, rule_version, and source_row_hash — and produces a sequence of append-only routing events: an owner assignment, an SLA timer, and any subsequent escalations. The routing layer never edits the query text or the source data; it is a read-only consumer of clinical data that writes only to its own operational store, pushing site-facing notifications through validated, role-based EDC interfaces governed by Role-Based Access Control for Clinical Data.

Before implementing the patterns below, the following are mandatory:

  • Regulatory baseline: A working command of 21 CFR Part 11 electronic-record requirements and ALCOA+ principles. Every assignment and escalation is a regulated electronic record and must be Attributable, Contemporaneous, and Enduring. The audit-trail expectations for these events are scoped by Audit Trail Boundaries in EDC Systems.
  • Data standards: Query records reference CDISC ODM FormOID/ItemOID paths; form-level ownership rules are keyed off those identifiers, so the routing matrix must align to the same annotated CRF locations described in CDISC ODM vs CDASH Schema Mapping.
  • Upstream contracts: Severity arrives on the query from generation and is calibrated through Discrepancy Threshold Tuning. Routing treats severity as an input, not something it recomputes.

Pin dependency versions explicitly so a routing decision made today reproduces byte-identically when re-run during an inspection two years from now. A representative requirements.txt for a routing node:

# Regulatory relevance: version pinning is a 21 CFR Part 11 reproducibility control.
# Re-running the routing engine against an archived event log MUST reproduce the
# same owner, SLA deadline, and escalation chain for every query.
pydantic==2.7.1
python-dateutil==2.9.0.post0
PyYAML==6.0.1

Environment assumptions: the routing tier runs as a stateless service against an append-only event store. It holds no mutable per-query state in memory — current status is always derived by folding the event log — which is what lets the service scale horizontally and replay history without corrupting the ledger.

Severity-by-owner routing decision matrix with override and default A grid with severity on the vertical axis (critical, borderline, low) and owner tier on the horizontal axis (site coordinator, lead CRA, medical monitor). Populated cells carry a route id and SLA in hours. Critical and a lab form resolves to the medical monitor via CRIT-LB-MM at 8 hours, drawn as the highlighted lab-form override. Critical on any other form resolves to the lead CRA via CRIT-ANY-LCRA at 12 hours. Borderline at an EU site resolves to the site coordinator via BORD-SITE-EU at 72 hours for self-correction. Cells with no specific rule show a dash. A footer band states that any unmatched context routes to DEFAULT-ESC, the medical monitor, at 24 hours, so routing is total and no query is left without an owner. Site coordinator Lead CRA Medical monitor Severity ↓ Owner → Critical Borderline Low CRIT-ANY-LCRA 12 h non-lab forms CRIT-LB-MM 8 h lab-form override form_oid = LB BORD-SITE-EU 72 h EU self-correct Unmatched context → DEFAULT-ESC · medical monitor · 24 h — total routing, no orphans

Routing Engine: Core Pattern

A production-grade router separates three concerns: the routing context (the attributes a decision depends on), the routing matrix (version-controlled data that maps context to an owner and SLA), and the transition function (a pure function that validates and applies a state change). Keeping the matrix as data — not branching logic scattered through the code — means a data manager can change an escalation rule as a reviewable Git commit, with no redeploy of the engine.

The routing context is a strongly typed, frozen model so malformed inputs fail loudly at the boundary instead of producing a silent, non-reproducible route:

# Regulatory relevance: an immutable routing context enforces ALCOA+ "Accurate"
# and "Original" — every attribute that influenced the routing decision is captured
# and cannot be mutated after the fact, so the decision is fully reconstructable.
from datetime import datetime, timedelta, timezone
from enum import Enum
from pydantic import BaseModel, ConfigDict


class Severity(str, Enum):
    CRITICAL = "critical"
    BORDERLINE = "borderline"
    LOW = "low"


class State(str, Enum):
    OPEN = "OPEN"
    ASSIGNED = "ASSIGNED"
    PENDING_SITE = "PENDING_SITE_RESPONSE"
    RESOLVED = "RESOLVED"
    ESCALATED = "ESCALATED"
    CLOSED = "CLOSED"


class RoutingContext(BaseModel):
    model_config = ConfigDict(frozen=True)

    query_key: str          # deterministic identity from the generation layer
    study_id: str
    site_id: str
    form_oid: str           # CDISC ODM FormOID -> drives form-level ownership
    severity: Severity
    region: str             # routing differs by regional CRO
    rule_version: str       # ties the decision to a frozen ruleset

Routes are resolved by looking the context up in a precedence-ordered matrix. The first matching rule wins, and an explicit default guarantees no query is ever left unrouted:

# Regulatory relevance: deterministic, total routing — every query resolves to
# exactly one owner with an SLA. A guaranteed default prevents "orphan" queries
# that would otherwise age outside any monitoring window (ALCOA+ "Available").
class RouteDecision(BaseModel):
    model_config = ConfigDict(frozen=True)
    route_id: str
    owner_role: str         # "site_coordinator" | "lead_cra" | "medical_monitor"
    sla_hours: int


def resolve_route(ctx: RoutingContext, matrix: list[dict]) -> RouteDecision:
    """Pure function: identical context + matrix version always yields one route."""
    for rule in matrix:                      # matrix is ordered by precedence
        if rule["severity"] not in (ctx.severity.value, "*"):
            continue
        if rule["form_oid"] not in (ctx.form_oid, "*"):
            continue
        if rule["region"] not in (ctx.region, "*"):
            continue
        return RouteDecision(
            route_id=rule["route_id"],
            owner_role=rule["owner_role"],
            sla_hours=rule["sla_hours"],
        )
    # Total function guarantee: anything unmatched escalates rather than vanishing.
    return RouteDecision(route_id="DEFAULT-ESC", owner_role="medical_monitor", sla_hours=24)

Because resolve_route is pure and the matrix is version-pinned, the same query under the same rule_version always routes to the same owner with the same SLA — the property an inspector relies on when reconstructing why a 2024 query reached a particular CRA.

State Transitions, SLA Timers, and the Audit Ledger

The current state of a query is never stored as a mutable column; it is derived by folding the append-only event log. A transition is accepted only if it is legal for the current state, which is what prevents circular routing and illegal jumps (for example, CLOSED → PENDING_SITE):

# Regulatory relevance: an append-only event ledger with validated transitions is
# the 21 CFR Part 11 audit trail. State is reconstructed by replay, so history can
# never be silently rewritten (ALCOA+ "Enduring", "Consistent", "Attributable").
import hashlib
import json

LEGAL_TRANSITIONS = {
    State.OPEN: {State.ASSIGNED},
    State.ASSIGNED: {State.PENDING_SITE, State.ESCALATED},
    State.PENDING_SITE: {State.RESOLVED, State.ESCALATED},
    State.RESOLVED: {State.CLOSED, State.ASSIGNED},   # reopen on insufficient response
    State.ESCALATED: {State.ASSIGNED},                # monitor reassigns
    State.CLOSED: set(),                              # terminal
}


def append_event(ctx: RoutingContext, prev: State, nxt: State,
                 actor: str, prev_hash: str, store) -> dict:
    if nxt not in LEGAL_TRANSITIONS[prev]:
        raise ValueError(f"Illegal transition {prev} -> {nxt} for {ctx.query_key}")

    event = {
        "query_key": ctx.query_key,
        "from_state": prev.value,
        "to_state": nxt.value,
        "actor": actor,                                  # system or named monitor id
        "occurred_at": datetime.now(timezone.utc).isoformat(),
        "rule_version": ctx.rule_version,
        "prev_hash": prev_hash,                          # hash chain over the ledger
    }
    event["event_hash"] = hashlib.sha256(
        (prev_hash + json.dumps(event, sort_keys=True)).encode()
    ).hexdigest()
    store.append(event)                                  # never updated in place
    return event

SLA enforcement is a side effect of time, not of an event, so it must be evaluated by a sweeper rather than waited on. The sweeper compares each open query’s deadline against the wall clock and emits an escalation event when breached — and, critically, it is idempotent so a re-run never double-escalates:

# Regulatory relevance: SLA breach escalation is a Contemporaneous control — aging
# queries surface to a medical monitor before they exit the monitoring window. The
# guard makes the sweep idempotent so repeated runs create no duplicate records.
def sla_deadline(assigned_at: datetime, sla_hours: int) -> datetime:
    return assigned_at + timedelta(hours=sla_hours)


def sweep_for_breaches(open_queries, now, store) -> list[dict]:
    escalations = []
    for q in open_queries:
        if q["state"] != State.ASSIGNED.value:
            continue
        if now < sla_deadline(q["assigned_at"], q["sla_hours"]):
            continue
        if q.get("escalated"):                           # already escalated -> skip
            continue
        escalations.append(
            append_event(q["ctx"], State.ASSIGNED, State.ESCALATED,
                         actor="sla-sweeper", prev_hash=q["head_hash"], store=store)
        )
    return escalations

The prev_hash/event_hash chain turns the ledger into a tamper-evident record: altering any historical assignment invalidates every downstream hash, so a sponsor or FDA reviewer can verify the routing history has not been rewritten. When a CRA reviews a query, the system surfaces the exact route id, matrix version, owner, and full transition chain that produced the assignment.

Configuration and Parameterization

The routing matrix, SLA windows, and escalation defaults live in version-controlled YAML, tagged on every database-lock milestone. Changing where critical lab queries route is then a reviewable change-management event, not a code deploy:

# Regulatory relevance: config-as-code. Every change to an ownership or SLA rule is
# a tracked Git commit subject to impact assessment and sign-off before promotion.
# The matrix is ordered: the first matching rule wins (most specific at the top).
matrix_version: "2026.06.0"          # tag aligned to the database-lock milestone
defaults:
  unmatched_owner: "medical_monitor"
  unmatched_sla_hours: 24
matrix:
  - route_id: "CRIT-LB-MM"           # critical lab discrepancies -> medical monitor
    severity: critical
    form_oid: "LB"
    region: "*"
    owner_role: "medical_monitor"
    sla_hours: 8
  - route_id: "CRIT-ANY-LCRA"        # any other critical -> lead CRA
    severity: critical
    form_oid: "*"
    region: "*"
    owner_role: "lead_cra"
    sla_hours: 12
  - route_id: "BORD-SITE-EU"         # borderline, EU sites -> site self-correction
    severity: borderline
    form_oid: "*"
    region: "EU"
    owner_role: "site_coordinator"
    sla_hours: 72

Configuration resolves through environment variables that bind a deployment to the correct EDC tenant and operational store, keeping development, validation, and production strictly segregated:

Variable Purpose Example
QR_EDC_TENANT Logical EDC workspace the node reads from study-1234-prod
QR_MATRIX_PATH Path to the pinned, version-controlled routing matrix /config/routing-2026.06.0.yml
QR_EVENT_STORE_DSN Append-only routing event store connection postgres://.../routing
QR_ENV Environment guard (dev/val/prod) prod

The matrix file and its JSON Schema live in the same repository as the engine; CI rejects any matrix change that lacks a matrix_version bump, so a silent edit can never reach production.

Testing and Validation

Because each routing rule is a regulated artifact, routing requires the same test rigor as clinical edit checks. Unit tests assert determinism (same context, same route), totality (every context resolves), legal-transition enforcement, and idempotent SLA sweeping. Mock query fixtures stand in for live records so tests run hermetically in CI:

# Regulatory relevance: automated regression tests are the OQ evidence that routing
# behavior is unchanged across releases — an IQ/OQ/PQ validation artifact retained
# alongside the traceability matrix linking each route rule to its protocol control.
import pytest


@pytest.fixture
def crit_lb(matrix) -> RoutingContext:
    return RoutingContext(
        query_key="q-abc123", study_id="STUDY-1234", site_id="1001",
        form_oid="LB", severity=Severity.CRITICAL, region="NA",
        rule_version="2026.06.0",
    )


def test_route_is_deterministic(crit_lb, matrix):
    assert resolve_route(crit_lb, matrix) == resolve_route(crit_lb, matrix)


def test_critical_lab_goes_to_monitor(crit_lb, matrix):
    decision = resolve_route(crit_lb, matrix)
    assert decision.owner_role == "medical_monitor"
    assert decision.sla_hours == 8


def test_illegal_transition_is_rejected(crit_lb, store):
    with pytest.raises(ValueError):
        append_event(crit_lb, State.CLOSED, State.PENDING_SITE,
                     actor="test", prev_hash="0" * 64, store=store)


def test_sweep_is_idempotent(breached_query, store, now):
    first = sweep_for_breaches([breached_query], now, store)
    breached_query["escalated"] = True               # mark as escalated
    second = sweep_for_breaches([breached_query], now, store)
    assert len(first) == 1 and len(second) == 0      # no duplicate escalation

GxP test artifacts — the test plan, executed results, and a traceability matrix linking each route rule to its monitoring-plan requirement — are retained with the IQ/OQ/PQ package. Run new or modified matrices in shadow mode against a historical event log first, confirming the derived owner and SLA match the validated baseline before they route live queries.

Production Gotchas and Failure Modes

Failure mode Root cause Remediation
Query routed to two owners at once Status held as a mutable column updated by concurrent workers Derive state by folding the append-only ledger; make transitions an atomic compare-and-append on head_hash
Aging query never escalates SLA evaluated only on inbound events, so an idle query is never re-checked Run an idempotent sweep_for_breaches on a schedule against the wall clock
Double escalation on sweeper re-run Sweep emits an event without checking prior escalation Guard on the escalated flag (or replay the ledger) so the sweep is idempotent
Circular routing / illegal jump Transitions applied without validating the current state Enforce LEGAL_TRANSITIONS; reject and dead-letter any illegal edge
Non-reproducible owner at inspection Matrix edited without a matrix_version bump Fail CI on untagged matrix changes; bind every event to its rule_version/matrix_version
Orphan query with no owner Matrix has gaps and no default Make resolve_route total — an explicit default escalates the unmatched query

The most damaging of these is the orphan: a query that resolves to no owner ages outside every dashboard and surfaces only at database lock. A total routing function with a guaranteed escalation default is the structural fix — never rely on the matrix being exhaustive by hand.

Multi-vendor studies add a further reconciliation burden: when regional CROs run separate EDC instances, the canonical routing state must reconcile asynchronous, conflicting status updates without overwriting legitimate site responses. That reconciliation — vector-clock ordering and idempotent upserts to a single ledger — is covered in depth in Syncing Discrepancy Status Across Multiple EDC Vendors.

Compliance Checklist

Use this as the change-management gate before promoting a routing matrix to production. Each item maps to an ALCOA+ or 21 CFR Part 11 control.