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

install.packages(
  "https://clincoder.cloud/p21bridge/p21bridge_0.0.1.9000.tar.gz",
  repos = NULL, type = "source"
)

Worked example

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"

How a finding is classified

  1. Rule id in the shipped catalogue (class_source = "catalogue"). The catalogue holds each rule’s canonical text, so classification does not depend on how one report vintage worded its message.
  2. Message text (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.
  3. Neither recognises it → 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.

Scope and limits

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).

  • 131 of the 537 rules name a mechanic 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.
  • The honest ceiling on the remaining 406, counted by data-raw/make-rule-catalogue.R, not estimated:
    • 387 are undecidable in principle. Which of two disagreeing values is right, what a missing unit or a missing result should be, whether a subject’s actual arm differs from the planned one for a good reason, what a missing dataset should contain — the remedy is in the protocol, the CRF or a clinician’s head, not in the data. No amount of handler writing reaches these, and a package that guessed at them would be worse than useless.
    • 19 need data p21bridge does not ship. MedDRA and WHODrug lookups (14), the five 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.
    • 0 are left in the “reachable with more work” bucket. 0.0.1.9000 named 48 rules there and enumerated them by pattern; all 48 now have a handler, and the same pattern is still in the build script, where it now matches nothing. That 0 is a measurement of that enumeration, not a claim that no rule could ever be reached — re-reading the 387 with a different eye may find more, as this release did by adding term-level NCI codes to sdtm_ct and reaching four rules the previous release had put in the undecidable pile.
    • Counts are printed by 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.
  • Handler coverage is not the same as findings coverage. Against the 1,840 real CDISCPILOT01 findings shipped in 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.
  • It is a snapshot, not a live feed. Rule ids newer than the harvested reports are absent and take the message path; DD (define.xml) and OD (ODM) rules are absent entirely, because no harvested report contained any.
  • 14 rule ids appeared in more than one report with different text or severity; the first report wins and the build script prints them.

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.

Where the shipped data comes from, and its licence

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.

Replaces

The PROC IMPORT + RENAME block per report vintage, the manual PROC CONTENTS / spec lookup per finding, the hand-maintained rule-id FORMAT or lookup dataset, the PROC FREQ against a pasted codelist, and the PROC FREQ over the report before the status call.

License

MIT (c) Bhanoji Duppada