Automating Query Closure with Response Parsing

A site coordinator types “corrected, please close” into a query response, the data value never changed, and an over-eager automation closes the query anyway — burying an unresolved discrepancy that resurfaces at database lock. The opposite failure is just as costly: a data manager manually re-reads thousands of routine “value confirmed and updated” responses that could have closed themselves. Automating query closure means threading that needle: parse the free-text response, but close a query only when the underlying data was actually corrected and the corrected value re-validates against the exact rule that raised the query. This deep-dive is the closure decision inside Automated Query Lifecycle Management, which sits within the broader Clinical Query Generation & Discrepancy Management discipline. The response text is evidence of intent, never proof of resolution; the re-validation is the proof, and both are captured under 21 CFR Part 11 as the auditable basis for the close.

The Closure Decision Flow

Closure is a guarded decision, not a text match. A response is normalized and classified, the current value is re-fetched and re-run through the original rule, and only the conjunction of a real correction and a clean re-validation permits an auto-close. Everything else routes to a human or stays open.

Guarded auto-close decision flow A top-down decision flow for closing a query. A site or CRA response is normalized and classified, then the current value is re-fetched and re-validated against the original rule. A first decision asks whether the data was corrected and re-validates cleanly; if yes the query is auto-closed. If no, a second decision asks whether the response is ambiguous or contains no data change; if yes it is routed to a human reviewer, and if no the query stays open and is reissued to the site. Site / CRA response free text + status Normalize + classify intent, not proof Re-fetch value + re-validate vs rule corrected AND re-validates? Auto-close query + push to EDC API ambiguous / no data change? Route to human manual adjudication Keep OPEN / reissue still fails the rule yes no yes no

Root Cause: Why Text Matching Alone Closes the Wrong Queries

A query response has two channels that automation routinely conflates: a structured status the site sets in the EDC, and free text the coordinator writes. Neither is trustworthy on its own. The status may read “answered” while the free text says “will fix next visit”; the free text may say “corrected” while the value in the database is unchanged because the update failed validation or was entered on the wrong visit. Closing on either channel alone is how an unresolved discrepancy gets marked resolved.

The deeper cause is that a response is a claim about a correction, not the correction itself. The only authoritative evidence that a discrepancy is resolved is the current value re-run through the exact rule — same rule_id, same rule_version — that raised the query. Free text is useful for classifying intent and for spotting ambiguity that a human must adjudicate, but it can never substitute for re-validation. This is why closure logic belongs downstream of the same edit-check engine that generated the query, reusing the deterministic checks described in Automated Clinical Query Generation rather than a second, drifting copy of the rule.

Step-by-Step Implementation

Each step is one runnable building block. Compose them into a closure worker that runs after site responses sync back into the pipeline.

1. Normalize and classify the response text

Lowercase, strip, and collapse whitespace, then map the response to a coarse intent using a small controlled vocabulary. Light normalization only — the intent gates how the query is handled, never whether it closes:

# 21 CFR Part 11 relevance: the classified intent is recorded as part of the close
# decision, but it is advisory. Re-validation, not text, authorizes the close.
import re

_CORRECTED = {"corrected", "updated", "amended", "fixed", "changed", "re-entered"}
_NO_CHANGE = {"confirmed correct", "value is correct", "no change", "as reported"}


def classify_response(text: str) -> str:
    norm = re.sub(r"\s+", " ", text.strip().lower())
    if any(p in norm for p in _NO_CHANGE):
        return "ASSERTS_NO_CHANGE"       # site says data was right as-is
    if any(w in norm for w in _CORRECTED):
        return "CLAIMS_CORRECTION"       # site says it changed the value
    return "AMBIGUOUS"                   # neither pattern -> human adjudication

2. Re-fetch the current value and re-validate against the original rule

Pull the current value for the queried SubjectKey/ItemOID and run it through the identical rule that raised the query. Re-validation returns a boolean plus the value seen, so the decision and its evidence are captured together:

# ALCOA+ relevance: Accurate + Original — the close is justified by re-running the
# exact rule_version against the live value, not by trusting the response text.
from decimal import Decimal


def revalidate(query: dict, current_value: str, rule_fn) -> dict:
    """rule_fn is the same deterministic check that raised the query."""
    passes = rule_fn(current_value, query["rule_version"])
    return {
        "rule_id": query["rule_id"],
        "rule_version": query["rule_version"],
        "value_seen": current_value,
        "revalidates": bool(passes),      # True only if the discrepancy is gone
    }

3. Decide closure only on correction AND clean re-validation

Combine the intent and the re-validation into a single guarded decision. The query closes only when the site claims a correction and the current value passes the original rule. A value that re-validates without a claimed change is still suspicious and is sent to a human:

# 21 CFR Part 11 relevance: an unresolved discrepancy must never auto-close. The
# conjunction below is the structural guarantee that closure implies resolution.
def decide(intent: str, reval: dict, value_changed: bool) -> str:
    if intent == "CLAIMS_CORRECTION" and value_changed and reval["revalidates"]:
        return "AUTO_CLOSE"
    if intent == "AMBIGUOUS" or (reval["revalidates"] and not value_changed):
        return "ROUTE_TO_HUMAN"          # cannot safely decide from text alone
    return "KEEP_OPEN"                   # still fails the rule -> reissue to site

4. Route ambiguous or unresolved responses to a human

An AUTO_CLOSE verdict transitions the query and pushes the close through the validated EDC API; anything else is queued for adjudication or reissued. Closure is never a silent database edit — it is the same official-interface write the lifecycle uses everywhere:

# 21 CFR Part 11 relevance: the close is written through the validated EDC query API
# with an attributable principal, never a direct DB mutation of query status.
def apply_verdict(query: dict, verdict: str, edc, store) -> dict:
    if verdict == "AUTO_CLOSE":
        edc.close_query(query["edc_query_id"], principal="closure-bot")
        to_state = "CLOSED"
    elif verdict == "KEEP_OPEN":
        edc.reissue_query(query["edc_query_id"], principal="closure-bot")
        to_state = "ROUTED"
    else:                                 # ROUTE_TO_HUMAN
        store.enqueue_manual_review(query["query_id"])
        to_state = "UNDER_REVIEW"
    return {"query_id": query["query_id"], "verdict": verdict, "to_state": to_state}

5. Emit an immutable auto-close audit record

Every closure decision — including a refusal to close — is a regulated record. Capture the response text, its classification, the re-validation evidence, the verdict, and the actor, hashed so the decision is reconstructable years later:

# ALCOA+ relevance: immutable audit of the close decision — Original + Enduring. A
# reversal is a new reopen event, never an edit of this record.
import hashlib
import json
from datetime import datetime, timezone


def audit_close(query: dict, intent: str, reval: dict, verdict: str) -> dict:
    entry = {
        "query_id": query["query_id"], "subject": query["subject"],
        "item_oid": query["item"], "response_intent": intent,
        "value_seen": reval["value_seen"], "revalidates": reval["revalidates"],
        "rule_id": reval["rule_id"], "rule_version": reval["rule_version"],
        "verdict": verdict, "actor": "closure-bot",
        "decided_at": datetime.now(timezone.utc).isoformat(),
    }
    entry["record_hash"] = hashlib.sha256(
        json.dumps(entry, sort_keys=True, separators=(",", ":")).encode()
    ).hexdigest()
    return entry                          # appended to write-once JSONL / Parquet

Verification and Audit Trail

Closure is GxP-relevant software, so “the query was closed correctly” must be provable from the log, not asserted. Capture a structured entry per decision within the boundaries a read-only consumer may record under Audit Trail Boundaries in EDC Systems.

Field Purpose (regulatory)
query_id / subject / item_oid Ties the close to a CDISC-annotated CRF location (Attributable)
response_intent The classified site claim that prompted review (Legible)
value_seen + revalidates The live value and whether it passes the original rule (Accurate)
rule_id + rule_version Pins re-validation to the exact rule that raised the query (Consistent)
verdict Auto-close, keep-open, or route-to-human, and why (Original)
decided_at / record_hash UTC instant and immutable proof of the decision (Contemporaneous, Enduring)

To confirm the logic, assert three properties against fixtures: a CLAIMS_CORRECTION response whose value still fails the rule yields KEEP_OPEN and never closes; a response that re-validates with no recorded value change yields ROUTE_TO_HUMAN; and re-running an identical closure batch produces the same record_hash set with zero new closes. Run new closure rules in shadow mode against a historical response corpus, comparing auto-close verdicts to the manual dispositions on record before letting them close live queries.

Edge Cases and Vendor-Specific Gotchas

Free-text agreement with no data change. A response of “yes, corrected, close please” with an unchanged value is the canonical trap. Because value_changed is false, the conjunction in step 3 refuses the auto-close and routes to a human — the text is treated as intent, and the absence of a real correction wins. Never let a status flag or a keyword override a failed re-validation.

Reopened queries and stale responses. A query that was closed and later reopened may carry an old response from its first life. Bind each response to the transition epoch it answers, and re-fetch the value at decision time so re-validation reflects the current casebook, not the state that existed before the reopen described in Automated Query Lifecycle Management.

Ambiguous multi-item responses. A single free-text answer sometimes addresses several items on a form (“fixed the weight, height was right”). Do not attempt to parse compound corrections heuristically; classify the whole response as AMBIGUOUS and route it to a human so no item closes on a misattributed sentence.

Frequently Asked Questions

Can a query auto-close on the site's response text alone?

No. The response text is classified only to detect intent and ambiguity. A query closes exclusively when the site claims a correction and the current value re-validates against the exact rule and rule version that raised the query. Text is evidence of intent; re-validation is the proof of resolution, and only the proof authorizes a close.

What happens when a response says "corrected" but the value never changed?

The closure decision requires both a claimed correction and a real data change that re-validates. If the value is unchanged, the conjunction fails, so the query is routed to a human reviewer rather than closed. This is the specific trap that manual, text-only automation falls into, and the guarded decision exists to prevent it.

How do you re-validate without drifting from the original rule?

Closure reuses the identical deterministic edit-check function and the pinned rule_version that generated the query, rather than a second copy of the logic. Because the rule is version-controlled and tagged to the database-lock milestone, re-running it against the current value reproduces exactly the check that raised the query, so a pass is a defensible basis for closure.

Should an ambiguous response ever be auto-closed?

Never. A response that matches neither the correction nor the no-change vocabulary is classified AMBIGUOUS and routed to manual adjudication. Compound responses that address several items at once are also treated as ambiguous so no item closes on a misattributed sentence.

How is an auto-close decision proven during an inspection?

Every decision — including refusals to close — writes an append-only record capturing the query and CRF coordinates, the classified intent, the live value seen, whether it re-validated, the rule id and version, the verdict, an attributable actor, a UTC timestamp, and a SHA-256 over the entry. An inspector can regenerate the identical verdict from the archived value and the pinned rule.