Close the loop between a CDISC validation report and the code that caused it.
A Pinnacle 21 report tells you that SD0027 failed on DM.USUBJID. It does not tell you which line of your mapping code or which row of your specification put it there, and it does not tell you how to fix it. Programmers reconcile that by hand, every run, on every study.
p21bridge does the reconciliation:
validation report (.xlsx/.csv) -> normalised findings -> traced to dataset + variable + spec row -> concrete fix
read_findings() — one tidy tibble whatever the report vintage.trace_findings() — every finding resolved to its dataset object, variable and specification row; anything unresolvable is flagged, never dropped.suggest_fix() — a specific remedy, recomputed from the data, plus the R expression that fixes it where that is safely derivable, across 22 deterministic finding classes.finding_summary() — counts by severity, domain and rule class for triage.rule_info() — what a rule id actually is, from the shipped catalogue of 537 Pinnacle 21 rules.ct_check() / ct_codelist() / ct_release() — conformance against the shipped CDISC SDTM controlled terminology, release 2026-03-27 (1,208 codelists, 45,566 submission values).
install.packages(
"https://clincoder.cloud/p21bridge/p21bridge_0.0.1.9000.tar.gz",
repos = NULL, type = "source"
)Runs as-is: the report, the datasets and the spec all ship with the package.
library(p21bridge)
ex <- readRDS(system.file("extdata", "example_study.rds", package = "p21bridge"))
findings <- read_findings(system.file("extdata", "findings.csv", package = "p21bridge"))
traced <- trace_findings(findings, ex$datasets, spec = ex$spec)
fixes <- suggest_fix(traced)
finding_summary(traced)
fixes[fixes$deterministic, c("rule_id", "fix_class", "class_source", "suggestion")]
#> SD0056 missing_variable catalogue Add required variable RFENDTC to DM (5 rows) ...
#> SD1310 variable_length message_regex Widen the declared length of DM.USUBJID to 20 (currently 12) ...
#> CT2001 ct_codelist catalogue AE.AESEV differs from CDISC CT 2026-03-27 codelist 'Severity/Intensity Scale for Adverse Events' (AESEV, non-extensible) only by case and/or padding ...
#> SD1011 iso8601_date catalogue Convert the 1 non-ISO value(s) of DM.RFSTDTC (format '%d%b%Y') ...
#> SD1117 key_not_unique catalogue Add the sequence variable AESEQ ...
#> SD0064 referential_integrity catalogue 1 orphan record(s) in AE (USUBJID: 'STUDY001-SITE01-0006') ...
# what is that rule, and how bad is it?
rule_info("SD0064")[, c("severity", "category", "message")]
#> Error Cross-reference Subject is not present in DM domain
# controlled terminology against the CDISC dictionary, not just your spec
ct_check(c("F", "Female"), "SEX")$conforms
#> [1] TRUE FALSE
# the emitted expression is real code - run it and the finding clears
DM <- ex$datasets$DM
eval(parse(text = fixes$r_expression[fixes$rule_id == "SD1011"]))
DM$RFSTDTC
#> [1] "2021-03-12" "2021-03-15" "2021-03-12" "2021-04-01" "2021-04-05"class_source = "catalogue"). The catalogue holds each rule’s canonical text, so classification does not depend on how one report vintage worded its message.class_source = "message_regex"), for rules newer than the catalogue and for hand-maintained issue logs. This is what 0.0.0.9000 did for everything, and it is still there.fix_class = "human_judgement", deterministic = FALSE, no expression. The package never guesses a mapping.Recognised classes: required variable absent · required value null · declared length shorter than the longest observed value · inconsistent length across split datasets · value outside the controlled terminology codelist · date not ISO 8601 · key or --SEQ not unique · orphaned records failing referential integrity · a variable the standard says should not be there · a missing Trial Summary parameter · DOMAIN disagreeing with the dataset name · STUDYID disagreeing with DM · columns out of the specification’s order · a label disagreeing with the specification · an ADaM variable name that breaks the rule’s own name template · a TSVAL in the wrong format for its parameter · a duplicated Trial Summary parameter · a variable or dataset missing from define.xml · a define.xml type the column contradicts · a --DT/--TM disagreeing with its --DTM · non-ASCII characters in a value · a --TEST that is not the CDISC term sharing its --TESTCD’s concept code.
A recognised class still returns deterministic = FALSE when the recomputation shows the remedy is not decided by the data — a prohibited variable that holds real values, a STUDYID that DM does not settle, a column order the specification does not cover, a label the specification does not declare. That restraint is why a deterministic = TRUE row can be trusted.
Rule catalogue — 537 rules. 306 SD, 225 AD, 6 CT; 423 Error, 113 Warning, 1 Notice. Harvested from Pinnacle 21 Community reports for the SDTM 3.2 and ADaM 1.0 configurations (data-raw/make-rule-catalogue.R).
suggest_fix() can recompute from the data (29 in 0.0.0.9000, 79 in 0.0.1.9000). The other 406 are "human_judgement" in the catalogue and fall through to the message path.data-raw/make-rule-catalogue.R, not estimated:
TSVAL parameters whose value is an external dictionary term (CURTRT, COMPTRT, INDIC, TRT, PCLAS — FDA SRS and NDF-RT), and the SDTM IG’s own variable type list.sdtm_ct and reaching four rules the previous release had put in the undecidable pile.Rscript data-raw/make-rule-catalogue.R; the three-way split is a judgement call made there by explicit pattern, so it can be re-run and argued with.inst/extdata, that report triggers only 8 distinct rules, so exactly one handler fires at all (drop_variable, on the 28 SD1076 findings) — and it declines every one of them, because all 28 flagged permissible variables are populated in the real data and dropping them would delete real values. None of the eight handlers added in this release fires on that report, and neither did six of the seven added in 0.0.1.9000. Their evidence is the unit tests, which recompute from data and evaluate every emitted expression. See NEWS.md.DD (define.xml) and OD (ODM) rules are absent entirely, because no harvested report contained any.Controlled terminology — CDISC SDTM CT release 2026-03-27. All 1,208 codelists and 45,566 submission values, with extensibility and each term’s NCI concept code. Nothing is subset away: only the columns no check uses (definition, synonyms, NCI preferred term) are dropped, which keeps sdtm_ct.rda to 285 KB (1,000 KB installed for the whole package, up from 864 KB). Call ct_release() and put the answer in your study documentation.
The term-level code column is new in this release and costs 105 KB installed (895 KB without it, 1,000 KB with). It buys the code/decode pairing check: CDISC gives a --TESTCD term and its --TEST term the same concept code, so the correct decode for a code is a lookup, not a study decision. That is four rules (CT2003, CT2006, SD0040, SD1043) that 0.0.1.9000 explicitly named as out of reach for exactly this reason.
SDTM CT only. ADaM, CDASH, SEND, define-XML and Protocol terminology do not ship, so a finding on an ADaM-only codelist falls back to your spec.
Data coded to a different CT release will disagree at the margins. Rebuild with data-raw/make-ct-dictionary.R against a newer NCI export.
When a specification declares a codelist, the specification wins — it is what your define.xml says. CDISC CT is used when the spec is silent, resolved from the codelist named in the finding message, or failing that from a codelist whose submission value equals the variable name (SEX, AESEV, NY). That last fallback can pick the wrong codelist for a variable that breaks the SDTM naming convention, which is why the evidence always names the codelist and release it used.
Extensibility is reported, not enforced: a value outside an extensible codelist is flagged but is not necessarily wrong, and deciding that is a medical-review judgement no package should make.
The rule catalogue and the real-findings fixture come from the Pinnacle 21 Community validator reports distributed inside phuse-org/phuse-scripts. That repository ships LICENSE.md, which is the MIT licence — “Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the”Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software” — so redistributing the rule text is permitted, with the copyright and permission notice preserved. The rule wording itself originates from the Pinnacle 21 Community validator that produced those reports; it is redistributed here as PHUSE published it, and is attributed in ?p21_rules.
Copies of the same two Test Data Factory reports also exist in phuse-org/TestDataFactory, which carries no licence file. Nothing is taken from there. The build script checks those copies on every run and fails if they ever hold a rule id the MIT-licensed sources do not — today they hold none, so excluding them costs nothing.
CDISC controlled terminology is published by CDISC/NCI EVS for use in implementing CDISC standards; only codelist names, submission values and extensibility flags ship, with the release identified in the data.