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.
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/ItemOIDpaths; 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.
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.
Related
- Clinical Query Generation & Discrepancy Management — the parent section this discipline belongs to.
- Automated Clinical Query Generation — produces the structured queries that this routing layer assigns and time-boxes.
- Cross-Form Data Validation Rules — the validation logic that generates the discrepancies feeding routing.
- Discrepancy Threshold Tuning — calibrates the severity that routing consumes as a decision input.
- Syncing Discrepancy Status Across Multiple EDC Vendors — reconciling routing state across heterogeneous EDC instances.
- Role-Based Access Control for Clinical Data — the access model behind owner-role assignment and site-facing notifications.