CDISC CRF Annotation for Discrepancy Tracking

The annotated CRF (aCRF) is usually treated as a submission artifact — a blank PDF with SDTM variable names written in the margins, produced late, read once by a reviewer, and never executed. That is a wasted asset. The same annotation that tells a regulator “this field maps to LBORRES in the LB domain” is exactly the metadata an edit-check engine needs to know what to validate, and exactly the traceability a discrepancy needs to be reconstructed at inspection. This page, part of the broader Clinical Query Generation & Discrepancy Management section, treats the aCRF as a machine-readable annotation map: a single, versioned source of truth in which every CRF field is linked to its SDTM variable, its controlled-terminology (CT) codelist, and the edit-check rule that fires when the collected value is wrong. Drive both the SDTM mapping and the discrepancy rules from that one map and they can never disagree — which is the whole game, because a query written against logic the aCRF does not describe is a query no inspector can trace back to the protocol.

Annotation Map as the Single Source of Truth

The annotation map sits between the collected CRF field and two consumers that must never diverge: the SDTM transformation that submits the value, and the edit-check engine that queries it. One record per field drives both.

Annotation map driving SDTM mapping and discrepancy detection A left-to-right flow. A CRF field captured in the EDC feeds a single annotation-map record that holds the SDTM domain and variable, the controlled-terminology codelist, and an edit-check rule. That one record fans out to two consumers: an SDTM mapping step that produces the submission variable, and an edit-check engine that evaluates the rule. When the edit check fails it emits a structured discrepancy carrying the annotation identity; both consumers also feed the define.xml traceability document. CRF field collected in EDC Annotation record domain · variable codelist edit_check_rule single source of truth SDTM mapping → submission variable Edit-check engine evaluate rule Discrepancy carries annotation id fail

Concepts and prerequisites

An annotation is a triple binding: a CRF field identity (which form, which item, expressed as an ODM ItemOID) is bound to an SDTM target (domain, variable, and — where the value is coded — a CT codelist), and to an edit_check_rule that constrains what the field may legally hold. CDISC prescribes the first two bindings; the third is the leverage this page adds. Because both the mapping and the validation read from the same record, a value that maps to VSORRES in the VS domain is validated by exactly the rule the aCRF advertises for VSORRES, and a discrepancy raised against it can name the annotation that produced it. That closes the loop the regulator cares about: collection → annotation → submission variable, and collection → annotation → query, are the same annotation.

Three CDISC layers must be understood before the map is trustworthy. The CDISC Operational Data Model (ODM) supplies the field identity — ItemOID under FormDef/ItemGroupDef — and the collection semantics you annotate; the field-level acquisition contract is covered in CDISC ODM vs CDASH Schema Mapping, and the form-by-form procedure for producing those bindings is Mapping EDC Forms to CDASH Standards Step by Step. SDTM is the submission target the annotation names. Controlled Terminology — NCI/CDISC CT codelists — governs which coded values are legal, and a codelist is versioned, so an annotation must pin the CT version it was validated against. The define.xml (Define-XML) document is the machine-readable metadata submission that a reviewer reads alongside the aCRF; the annotation map is precisely the data that should generate it, so the map and the define are two renderings of one source, never two hand-maintained artifacts that drift.

The edit-check rules this map drives are the same relational and single-field predicates covered in Cross-Form Data Validation Rules; the difference here is provenance — every rule is anchored to an annotated field rather than floating free in code. The reference implementation pins its dependencies so annotation behavior is reproducible across IQ/OQ/PQ environments:

Dependency Pinned version Role in CRF annotation
python 3.11.x Runtime; dataclasses/tomllib for structured annotation loading
pydantic 2.7.x Schema validation of the annotation-map JSON at load time
jsonschema 4.22.x Enforces the annotation-map JSON Schema in CI
pandas 2.2.x Reads the collected EDC extract for coverage + CT checks
lxml / defusedxml 5.2.x Parses ODM metadata and emits/reads define.xml
pytest 8.2.x Coverage and conformance regression artifacts as GxP evidence

Implementation: the annotation map and its two consumers

The core discipline is that the annotation map is data, not code, and both the SDTM mapping and the edit-check engine are pure functions of that data. An annotation is loaded and validated once; the mapper reads its domain/variable/codelist, and the checker reads its edit_check_rule. Neither embeds a hard-coded field name or threshold, so a protocol amendment that moves a field is a change to one record, not a hunt through two codebases.

# ALCOA+ requirement: each annotation carries an immutable id + version so both
# the submission variable and any discrepancy are attributable to one exact
# annotation state (Attributable, Consistent).
from dataclasses import dataclass


@dataclass(frozen=True)
class Annotation:
    annotation_id: str      # e.g. "ACRF-LB-ALT-001", stable across amendments
    item_oid: str           # ODM ItemOID of the collected CRF field
    form_oid: str           # ODM FormDef the field lives on, for lineage
    domain: str             # SDTM domain, e.g. "LB"
    variable: str           # SDTM variable, e.g. "LBORRES"
    codelist: str | None    # CT codelist OID, e.g. "C66742" (No/Yes), or None
    ct_version: str | None  # CT package the codelist was validated against
    edit_check_rule: str    # rule id resolved against the check registry
    origin: str             # "CRF" (collected) | "DERIVED" | "ASSIGNED"
    version: str            # bumped on any change; maps to amendment_ref
    amendment_ref: str      # protocol amendment that last touched this field

The map as a whole is keyed by item_oid so every consumer looks a field up the same way the ODM export exposes it. The SDTM mapping step is then a projection: it reads the collected value for an item_oid, resolves the annotation, and writes the value into the named domain.variable, refusing to emit anything for a field the map does not annotate.

# 21 CFR Part 11 relevance: the submission value is produced only through an
# annotated path, so every SDTM cell is traceable to its aCRF record (Original).
def map_to_sdtm(record: dict, ann: Annotation) -> dict:
    """Project one collected CRF value onto its annotated SDTM target.
    A derived variable must never be produced from a collected-field annotation."""
    if ann.origin != "CRF":
        raise ValueError(
            f"{ann.annotation_id}: origin={ann.origin} is not a collected field; "
            "derived/assigned targets belong to the derivation spec, not the aCRF."
        )
    return {
        "STUDYID": record["study_oid"],
        "USUBJID": record["usubjid"],
        "DOMAIN": ann.domain,
        ann.variable: record["value"],
        # lineage stamped on every produced cell for reconstruction at inspection
        "_annotation_id": ann.annotation_id,
        "_annotation_version": ann.version,
        "_ct_version": ann.ct_version,
    }

The edit-check engine reads the same annotation and resolves edit_check_rule against a registry of predicates. Because the rule id lives on the annotation, the check that fires against a value is definitionally the check the aCRF advertises for that variable — the mapping and the validation cannot describe the field differently.

# ALCOA+ requirement: a discrepancy names the annotation that raised it, so the
# query is reconstructable from a frozen data state + a versioned map (Complete).
from typing import Callable

# registry maps an edit_check_rule id -> pure predicate(value, ann) -> bool(pass)
CHECK_REGISTRY: dict[str, Callable[[object, Annotation], bool]] = {}


def edit_check(record: dict, ann: Annotation) -> dict | None:
    predicate = CHECK_REGISTRY[ann.edit_check_rule]  # KeyError = unregistered rule
    value = record["value"]
    if predicate(value, ann):
        return None                                  # passes: no discrepancy
    return {
        "usubjid": record["usubjid"],
        "form_oid": ann.form_oid,
        "item_oid": ann.item_oid,
        "sdtm_target": f"{ann.domain}.{ann.variable}",
        "failing_value": value,
        "rule_id": ann.edit_check_rule,
        "annotation_id": ann.annotation_id,          # binds query -> aCRF record
        "annotation_version": ann.version,
    }

A concrete pair of rules shows how a codelist annotation and a range annotation share the registry. The CT-membership check reads the annotated codelist and asserts the collected value is a legal submission value; the range check reads bounds carried as rule parameters. Both are pure and stateless, so re-running against a frozen extract is idempotent.

# ALCOA+ requirement: coded values are validated against the annotated CT
# codelist + version, never a hard-coded literal set (Accurate).
def register(rule_id: str):
    def _wrap(fn):
        CHECK_REGISTRY[rule_id] = fn
        return fn
    return _wrap


CT_CODELISTS: dict[str, set[str]] = {}   # loaded from the pinned CT package

@register("CT-MEMBERSHIP")
def ct_membership(value, ann: Annotation) -> bool:
    if value in (None, ""):
        return True                       # emptiness is a completeness rule, not CT
    legal = CT_CODELISTS.get(ann.codelist, set())
    return str(value) in legal            # miss -> discrepancy, never imputed

@register("LB-ALT-RANGE")
def alt_plausible(value, ann: Annotation) -> bool:
    return value is None or 0 < float(value) < 5000   # U/L implausibility guard

Implementation: derived fields, drift, and compliance constraints

The dangerous annotations are the ones that do not correspond to a collected box. SDTM tabulations contain derived variables (--DY study days, --STRESN standardized results, epoch assignments) and assigned variables (STUDYID, DOMAIN) that appear on no CRF page. Annotating them as if they were collected fields — the classic aCRF sin — tells the mapper to read a value that was never captured and tells the checker to query a field the site cannot fix. The map must therefore carry origin and the mapper must refuse to project anything but origin == "CRF", as above; derived targets are documented in the derivation spec and the define.xml Origin element as Derived, not in the collected-field path.

The second failure mode is annotation drift after an amendment. When a protocol amendment renames, moves, or retires a field, three things must move together: the ODM form, the annotation record, and the edit-check rule it references. If only the form changes, the annotation now points at an item_oid that no longer exists and the field is silently dropped from both submission and validation — a false negative that produces missing queries, the hardest defect to detect. The guard is an explicit coverage reconciliation between the live ODM export and the map on every amendment, treated as a release gate.

# ALCOA+ requirement: Complete + Accurate. Every collected item in the current
# ODM must resolve to exactly one annotation, or the run fails closed — an
# unannotated field is never silently excluded from validation.
def reconcile_coverage(odm_item_oids: set[str],
                       annotations: dict[str, Annotation]) -> dict[str, list[str]]:
    annotated = set(annotations)
    collected = {oid for oid, a in annotations.items() if a.origin == "CRF"}
    report = {
        # collected fields present in ODM but with no annotation -> would drop
        "unannotated_fields": sorted(odm_item_oids - annotated),
        # annotations whose field vanished from the ODM after an amendment
        "orphaned_annotations": sorted(annotated - odm_item_oids),
        # duplicate SDTM targets that would double-map one value
        "duplicate_targets": _find_duplicate_targets(annotations),
    }
    if report["unannotated_fields"] or report["orphaned_annotations"]:
        raise RuntimeError(f"aCRF coverage gate failed: {report}")
    return report

The third constraint is CT version alignment. A codelist annotation validated against CT package 2024-03-29 is not valid against 2025-06-27 if a term was retired between them; a coded value that was legal at collection can become illegal after a CT upgrade. The annotation therefore pins ct_version, and a CT upgrade is a reviewed change that re-validates every affected annotation rather than a silent library bump — the same discipline the terminology-normalization step applies in the CDASH mapping walkthrough. Missing operands follow the cross-form rule: a null value routes to a completeness check, never to a silent CT pass, so an inspector can tell “clean” from “absent.”

Configuration and parameterization

The annotation map is a version-controlled JSON document validated against a JSON Schema at load and in CI. JSON — not code — is what lets a clinical data manager review an amendment diff without reading Python, and it is the exact shape that generates both the define.xml and the human-readable aCRF. The schema below is the contract: crf_field keys map to {domain, variable, codelist, edit_check_rule, …} records.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "aCRF annotation map",
  "type": "object",
  "required": ["catalog_version", "ct_package", "annotations"],
  "properties": {
    "catalog_version": { "type": "string" },
    "ct_package": { "type": "string", "description": "pinned CT release, e.g. 2025-06-27" },
    "annotations": {
      "type": "object",
      "additionalProperties": {
        "type": "object",
        "required": ["annotation_id", "domain", "variable", "origin",
                     "edit_check_rule", "version", "amendment_ref"],
        "properties": {
          "annotation_id":   { "type": "string", "pattern": "^ACRF-[A-Z]{2}-" },
          "form_oid":        { "type": "string" },
          "domain":          { "type": "string", "pattern": "^[A-Z]{2}$" },
          "variable":        { "type": "string", "pattern": "^[A-Z]{2,8}$" },
          "codelist":        { "type": ["string", "null"] },
          "ct_version":      { "type": ["string", "null"] },
          "edit_check_rule": { "type": "string" },
          "origin":          { "enum": ["CRF", "DERIVED", "ASSIGNED"] },
          "version":         { "type": "string" },
          "amendment_ref":   { "type": "string" }
        }
      }
    }
  }
}

A populated fragment keyed by item_oid (the crf_field) shows a coded field and a numeric field side by side:

{
  "catalog_version": "2026.07.0",
  "ct_package": "2025-06-27",
  "annotations": {
    "IT.LB.ALT": {
      "annotation_id": "ACRF-LB-ALT-001", "form_oid": "F.LB",
      "domain": "LB", "variable": "LBORRES", "codelist": null, "ct_version": null,
      "edit_check_rule": "LB-ALT-RANGE", "origin": "CRF",
      "version": "1.1.0", "amendment_ref": "PROTO-AMD-04"
    },
    "IT.AE.AESER": {
      "annotation_id": "ACRF-AE-SER-002", "form_oid": "F.AE",
      "domain": "AE", "variable": "AESER", "codelist": "C66742", "ct_version": "2025-06-27",
      "edit_check_rule": "CT-MEMBERSHIP", "origin": "CRF",
      "version": "2.0.0", "amendment_ref": "PROTO-AMD-06"
    }
  }
}

Environment-specific values — the ODM export location, the CT package store, the define.xml output bucket — belong in environment variables, never in the map, so the same validated map promotes unchanged from DEV through PROD. The map’s git SHA and catalog_version are recorded on every produced SDTM cell and every discrepancy, which is what binds a query to an exact, reviewable annotation. The validated map is loaded through a pydantic model so a malformed record fails the pipeline at startup rather than skipping in a nightly run.

# ALCOA+ requirement: config is validated at load so a bad annotation fails the
# run (Legible, Accurate) rather than silently excluding a field in production.
import json, jsonschema
from pydantic import TypeAdapter

_ADAPTER = TypeAdapter(dict[str, Annotation])

def load_map(path: str, schema_path: str) -> dict[str, Annotation]:
    raw = json.loads(open(path, encoding="utf-8").read())
    jsonschema.validate(raw, json.loads(open(schema_path, encoding="utf-8").read()))
    # inject the map key (item_oid) into each record before typed parse
    merged = {oid: {**rec, "item_oid": oid} for oid, rec in raw["annotations"].items()}
    return _ADAPTER.validate_python(merged)

Testing and validation

GxP test artifacts require that the annotation map is proven complete and CT-conformant against a real ODM export, and that every registered edit-check rule has a passing and a failing case. The coverage test is the load-bearing one: it is the executable proof that no collected field escapes annotation, and its report is archived as OQ evidence.

# OQ requirement: documented evidence that the aCRF annotates every collected
# field and that no annotation is orphaned after the latest amendment.
import pytest
from acrf.engine import reconcile_coverage, edit_check, load_map
from acrf.registry import CHECK_REGISTRY


def test_every_collected_field_is_annotated(odm_item_oids, annotation_map):
    report = reconcile_coverage(odm_item_oids, annotation_map)   # raises on gap
    assert report["unannotated_fields"] == []
    assert report["orphaned_annotations"] == []


def test_ct_membership_flags_illegal_code(annotation_map):
    ann = annotation_map["IT.AE.AESER"]                 # codelist C66742 (No/Yes)
    disc = edit_check({"usubjid": "S001", "value": "MAYBE"}, ann)
    assert disc is not None
    assert disc["annotation_id"] == "ACRF-AE-SER-002"   # query traces to aCRF
    assert disc["annotation_version"] == "2.0.0"


def test_every_referenced_rule_is_registered(annotation_map):
    referenced = {a.edit_check_rule for a in annotation_map.values()}
    assert referenced <= set(CHECK_REGISTRY)            # no dangling rule ids

A conformance test asserts that every annotated codelist exists in the pinned CT package and that every coded value in a historical locked extract is a member — the same shadow-mode discipline used before any rule generates live queries, run against a frozen dataset so the result is reproducible. The define.xml round-trip is a third artifact: generate the define from the map, re-parse it, and assert the domain/variable/Origin for each item matches the map, proving the two renderings agree.

Production gotchas and failure modes

  • Unannotated field silently dropped. A new field is added to the ODM in an amendment but not to the map; both the SDTM mapping and every edit check skip it, producing missing submission columns and missing queries. Remediation: run reconcile_coverage as a fail-closed release gate on every amendment — an unannotated collected field must halt the pipeline, never coalesce to null.
  • Annotation drift after amendment. An amendment renames IT.LB.ALATIT.LB.ALT; the annotation still keys on ALAT, so it is orphaned and the field is unmapped. Remediation: the coverage gate surfaces orphaned_annotations; treat any orphan as a blocking failure and update the map, the rule, and the ODM in one reviewed PR.
  • Codelist version mismatch. A field validated against CT 2024-03-29 is checked against 2025-06-27 after a library bump; a term retired between packages now fails CT-MEMBERSHIP for values that were legal at collection. Remediation: pin ct_version per annotation and gate CT upgrades behind a re-validation of every affected annotation, not a silent dependency change.
  • Derived variable annotated as collected. --DY or --STRESN is annotated with origin: CRF; the mapper tries to read a value the site never entered and the checker queries an underivable field. Remediation: enforce origin in map_to_sdtm, keep derived targets in the derivation spec, and mark them Derived in define.xml.
  • Duplicate SDTM target. Two annotations map different collected fields to the same domain.variable, double-writing one submission cell nondeterministically. Remediation: _find_duplicate_targets fails the load; a legitimate many-to-one collapse must be expressed as an explicit derivation, not two collected annotations.

Compliance checklist