Reproduce an R Submission to the FDA End to End: The pharmaverse Capstone

Thread one dataset through the whole submission pipeline this series taught in pieces — build an ADSL with admiral, export a compliant XPT with xportr, check it against its spec with metacore and metatools, and bundle the programs with pkglite — then meet the R Consortium pilots that proved an all-R FDA submission works

Pharma & Clinical

The submission-packaging series taught each step on its own: derive an ADaM dataset, export an XPT, read and check the Define-XML metadata, write the reviewer’s guides, bundle the programs. This capstone threads them into one continuous pipeline on a single dataset — build a subject-level analysis dataset (ADSL) from CDISC SDTM with admiral, export it to a compliant SAS Transport v5 file with xportr, check it against a metacore spec with metatools (a controlled-terminology check that earns its keep), and package the build programs into one reviewable text artifact with pkglite. Then it sets the whole thread in the real R Consortium FDA pilots — the first public all-R test submissions — with a precise, no-overclaim account of what the FDA actually did and did not do. Every stage runs on public pharmaverse data.

Published

July 1, 2026

Modified

July 7, 2026

TipKey takeaways
  • This is the whole submission thread on one dataset. The earlier lessons taught each step alone; here a single subject-level dataset flows through all four — build → export → check → package — so you see the pipeline as one continuous piece of work, not five isolated tools.
  • Build with admiral, export with xportr, check with metacore/metatools, package with pkglite. Derive an ADSL (Subject-Level Analysis Dataset, one row per subject) from SDTM (Study Data Tabulation Model) source, write a compliant XPT (SAS Transport version 5) file, validate it against a spec, and bundle the programs into one reviewable text artifact.
  • The metadata check earns its keep. Checking the exported dataset against its spec catches a real drift — a treatment value that is not in the spec’s controlled-terminology codelist — which is exactly what the gate is for, not a tool failure.
  • This is the shape of a real all-R FDA submission. The R Consortium submission pilots delivered public, fully R-based test submissions to the FDA using this same pharmaverse toolchain.
  • The FDA is software-agnostic — the pilots prove feasibility, not endorsement. The FDA reviewed and completed its review of an all-R submission; that demonstrates the pipeline is acceptable and gives a public working example. It is not “R is FDA-approved.” The FDA reviews submissions, it does not certify a language.

Introduction

You have learned each packaging step on its own. You derived a subject-level dataset, exported it to a transport file, read and checked its metadata, wrote the reviewer’s guides, and bundled the analysis programs. Each lesson stood alone. A real submission is not five separate exercises, though — it is one dataset travelling through all of them. This capstone threads them together: we take a single ADSL (Subject-Level Analysis Dataset — one row per subject, the spine every other analysis dataset joins to) from raw SDTM (Study Data Tabulation Model) source all the way to a reviewable submission artifact, running every stage for real.

Four stages, one dataset:

  1. Build — derive the ADSL from SDTM DM and EX with admiral.
  2. Export — write it to a compliant XPT (SAS Transport version 5) file with xportr.
  3. Check — validate it against a spec with metacore and metatools.
  4. Package — bundle the build programs into one text artifact with pkglite.

Then we place the whole thread in its real-world context: the R Consortium submission pilots, the first public all-R test submissions to the FDA, which used exactly this toolchain. Each stage below links back to the lesson that taught it in depth — here we keep every step compact so the thread is the point.

A left-to-right flow schematic of an all-R FDA submission pipeline in six boxes joined by arrows, showing one dataset moving through every stage. Box 1 (light blue): SDTM, the source data DM plus EX. Box 2 (mid blue): admiral, build the ADSL for 306 subjects. Box 3 (mid blue): xportr, export a compliant XPT v5 with strict_checks. Box 4 (dark navy, the centrepiece): metacore and metatools, check the data against its spec and controlled terminology. Box 5 (mid blue): pkglite, bundle the programs into one ASCII text file. Box 6 (brand azure): eCTD, the assembled submission package. The visual point is that a single dataset is built, exported, checked, and packaged into the deliverable a regulator receives — the shape of an all-R submission.

Stage 1 — Build the ADSL with admiral

The pipeline starts with data a regulator actually collects: SDTM domains. We derive the ADSL from two of them — DM (Demographics) and EX (Exposure) — using admiral, the pharmaverse package for building ADaM (Analysis Data Model) datasets. We keep the derivation deliberately compact: carry the planned and actual treatment arms across, derive the first-dose date TRTSDT, and set the SAFFL (Safety Population Flag). The full ADSL derivation — every population flag, every treatment variable, every date — is its own lesson; here we build just enough to have a genuine one-row-per-subject analysis dataset to carry forward.

library(admiral)
library(pharmaversesdtm)

data("dm", package = "pharmaversesdtm")
data("ex", package = "pharmaversesdtm")
dm <- convert_blanks_to_na(dm)
ex <- convert_blanks_to_na(ex)

adsl <- dm |>
  dplyr::select(-DOMAIN) |>
  dplyr::mutate(TRT01P = ARM, TRT01A = ACTARM) |>
  # First treatment start date from EX (first dosed record).
  derive_vars_merged(
    dataset_add = derive_vars_dtm(ex, dtc = EXSTDTC, new_vars_prefix = "EXST"),
    filter_add  = (EXDOSE > 0 | (EXDOSE == 0 & grepl("PLACEBO", EXTRT))) & !is.na(EXSTDTM),
    new_vars    = exprs(TRTSDTM = EXSTDTM),
    order       = exprs(EXSTDTM, EXSEQ), mode = "first",
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>
  derive_vars_dtm_to_dt(source_vars = exprs(TRTSDTM)) |>
  # Safety flag: dosed subjects are "Y", everyone else "N" (a Y/N flag is never NA).
  derive_var_merged_exist_flag(
    dataset_add = ex, by_vars = exprs(STUDYID, USUBJID),
    new_var = SAFFL, condition = (EXDOSE > 0 | (EXDOSE == 0 & grepl("PLACEBO", EXTRT))),
    false_value = "N", missing_value = "N"
  )

# Keep the submission-relevant subject-level columns.
sub <- adsl[, c("STUDYID", "USUBJID", "SUBJID", "SITEID", "AGE", "SEX",
                "RACE", "TRT01P", "TRT01A", "TRTSDT", "SAFFL")]

cat("ADSL rows:", nrow(sub),
    "| one row per subject:", nrow(sub) == length(unique(sub$USUBJID)), "\n")
ADSL rows: 306 | one row per subject: TRUE 

One row per subject, 306 subjects. Look at the three variables that carry the derivation — the safety flag, the planned treatment, and the derived date:

table(sub$SAFFL)                 # Y = dosed (safety population), N = not dosed

  N   Y 
 52 254 
table(sub$TRT01P)                # planned treatment arm, straight from DM

             Placebo       Screen Failure Xanomeline High Dose 
                  86                   52                   84 
 Xanomeline Low Dose 
                  84 
class(sub$TRTSDT)                # a real Date object
[1] "Date"

Read this the way a reviewer would. SAFFL is Y for the 254 dosed subjects and N for 52 who were not — and because we passed false_value = "N", missing_value = "N", it is never NA, which is what a Y/N population flag must be. TRT01P (Planned Treatment for Period 01) shows the three analysis arms plus Screen Failure — the raw arm value from DM. Hold that thought: it is the drift Stage 3 will catch. TRTSDT is a genuine Date, which matters the moment we export it.

Stage 2 — Export a compliant XPT with xportr

The ADSL is analysis-ready but it cannot ship as an .rds. A submission moves datasets as XPT (SAS Transport version 5) files. We use xportr to apply a metadata spec — types, lengths, labels, formats, order — and write the transport file, with strict_checks = TRUE so a conformance violation aborts the write instead of shipping a warning. The one trap to remember from the full XPT export lesson: an ADaM date is exported as type = "numeric" with a DATE9. display format, never type = "date" — that is how TRTSDT round-trips as a real date.

library(xportr)
library(haven)

# The variable-level spec — the same information a Define-XML carries.
var_spec <- data.frame(
  dataset  = "ADSL",
  variable = c("STUDYID", "USUBJID", "SUBJID", "SITEID", "AGE", "SEX",
               "RACE", "TRT01P", "TRT01A", "TRTSDT", "SAFFL"),
  type     = c("character", "character", "character", "character", "numeric",
               "character", "character", "character", "character", "numeric", "character"),
  length   = c(12, 40, 10, 10, 8, 2, 40, 40, 40, 8, 2),
  label    = c("Study Identifier", "Unique Subject Identifier", "Subject Identifier",
               "Study Site Identifier", "Age", "Sex", "Race",
               "Planned Treatment for Period 01", "Actual Treatment for Period 01",
               "Date of First Exposure", "Safety Population Flag"),
  format   = c("", "", "", "", "", "", "", "", "", "DATE9.", ""),  # a numeric date -> DATE9.
  order    = 1:11,
  stringsAsFactors = FALSE
)
df_spec <- data.frame(dataset = "ADSL", label = "Subject-Level Analysis Dataset")

xpt_path <- file.path(tempdir(), "adsl.xpt")   # member name must be <= 8 characters
sub |>
  xportr_metadata(var_spec, "ADSL") |>
  xportr_type()                     |>
  xportr_length(length_source = "metadata") |>
  xportr_label()                    |>
  xportr_order()                    |>
  xportr_format()                   |>
  xportr_df_label(df_spec)          |>
  xportr_write(xpt_path, strict_checks = TRUE)

cat("XPT written:", file.exists(xpt_path),
    "| size:", file.info(xpt_path)$size, "bytes\n")
XPT written: TRUE | size: 67200 bytes

The informational rules confirm every variable was found in the spec and the columns were ordered. Because we passed strict_checks = TRUE, the file was only written because it is conformant — a submission gate, not a courtesy. Now read it back and prove the transport survived the round-trip, especially the date:

back <- read_xpt(xpt_path)

dim(back)                          # 306 x 11, restored from the transport file
[1] 306  11
attr(back, "label")                # the dataset label survived
[1] "Subject-Level Analysis Dataset"
class(back$TRTSDT)                 # "Date" -> the numeric + DATE9. spec worked
[1] "Date"
back$TRTSDT[1]                     # a real calendar date
[1] "2014-01-02"

The dataset comes back at full size, the dataset label "Subject-Level Analysis Dataset" is attached, and TRTSDT is a genuine Date — proof the numeric + DATE9. pattern round-tripped. That .xpt is the object that lands in analysis/adam/datasets/.

Stage 3 — Check the data against its spec with metacore and metatools

An exported file is not a checked file. A submission dataset must conform to its Define-XML metadata — the machine-readable spec that lists every variable, its codelist, and its controlled terminology. We load a real CDISC pilot spec as a metacore object and use metatools to check the exported data against it. Controlled terminology (CT) is the fixed set of permitted values a variable may take — and check_ct_col() verifies a column’s values all live in its codelist. Reading and checking metadata in depth is the Define-XML lesson; here we run two checks and read the result honestly.

library(metacore)
library(metatools)

# Load the pilot ADaM spec (an .rda holding a `metacore` object).
e <- new.env()
load(metacore_example("pilot_ADaM.rda"), envir = e)
mc <- get("metacore", envir = e)
adsl_mc <- select_dataset(mc, "ADSL", verbose = "silent")

# SEX: every value is in the codelist -> the check passes, nothing to report.
get_bad_ct(back, adsl_mc, SEX)
character(0)

character(0)SEX is clean: every value (M/F) is in the spec’s codelist, so there is nothing to report. Now run the same check on the planned treatment. This one is not clean, and that is the point:

# TRT01P: the check compares each value to the spec's CL.ARM codelist.
bad <- check_ct_col(back, adsl_mc, TRT01P)
Warning: ✖ Invalid controlled terminology detected
ℹ Variable: TRT01P | Codelist: CL.ARM
ℹ Values not permitted 'Screen Failure'
get_bad_ct(back, adsl_mc, TRT01P)   # the value(s) not permitted by the codelist
[1] "Screen Failure"

The check flags "Screen Failure" as a value not permitted by the CL.ARM codelist — and this is the gate earning its keep, not a bug. Our compact Stage 1 build carried the raw DM arm value straight into TRT01P; the pilot’s Define-XML lists only the three analysis treatments in CL.ARM. check_ct_col() correctly caught a data value that is not in the spec — exactly the drift a conformance check exists to find. In a real submission, an ADSL nulls TRT01P for screen-failure subjects (they have no planned analysis treatment), which would clear the finding. Seeing SEX pass silently and TRT01P catch the drift is the whole value of a spec-driven check: it tells you precisely where data and metadata disagree, before a reviewer does.

Stage 4 — Package the programs with pkglite

A submission ships the datasets and the metadata — and the programs that produced them. The eCTD (electronic Common Technical Document, the standard package format a regulator receives) wants those programs as a single, flat, reviewable ASCII text file, not a folder of loose .R scripts. We use pkglite to bundle the ADSL build programs into one artifact — closing the loop, because the same code that built the dataset in Stage 1 is what gets packaged here. The full pkglite round-trip — the wire format, the ASCII gate, unpack() — is its own lesson; here we run the three verbs end to end.

library(pkglite)

# A small package holding the ADSL build program (stands in for your analysis package).
pkg <- file.path(tempdir(), "adslbuild")
dir.create(file.path(pkg, "R"), recursive = TRUE, showWarnings = FALSE)
writeLines(c(
  "Package: adslbuild", "Type: Package",
  "Title: ADSL Build Programs for Submission", "Version: 0.1.0",
  "Description: Analysis programs that derive ADSL.", "License: MIT"
), file.path(pkg, "DESCRIPTION"))
writeLines(c(
  "#' Build ADSL from SDTM DM and EX",
  "build_adsl <- function(dm, ex) {",
  "  # derive treatment vars, first-dose date, and SAFFL",
  "  dm",
  "}"
), file.path(pkg, "R", "build_adsl.R"))

# collate -> pack -> verify_ascii (the eCTD gate) -> unpack.
fc      <- collate(pkg, file_ectd())          # file_ectd() takes NO arguments
out_txt <- file.path(tempdir(), "pkglite.txt")
pack(fc, output = out_txt, quiet = TRUE)

cat("pkglite.txt written:", file.exists(out_txt),
    "| size:", file.info(out_txt)$size, "bytes",
    "| ASCII:", verify_ascii(out_txt, quiet = TRUE), "\n")
pkglite.txt written: TRUE | size: 518 bytes | ASCII: TRUE 

One text file, and verify_ascii() returns TRUE — it is submission-ready. Prove the round-trip restores the programs a reviewer can read and run:

restore <- file.path(tempdir(), "restored")
unpack(out_txt, output = restore, quiet = TRUE)

list.files(restore, recursive = TRUE)   # the package tree, reconstructed
[1] "adslbuild/DESCRIPTION"    "adslbuild/R/build_adsl.R"

The adslbuild package is back — DESCRIPTION and R/build_adsl.R — reconstructed from the single text file. That artifact is what physically sits in the eCTD’s m5 analysis-programs location, and what the ADRG (Analysis Data Reviewer’s Guide) inventories in its Submission of Programs section.

You just built a submission, in miniature

Step back and look at what the four stages did with one dataset. You derived an analysis dataset from CDISC SDTM, exported it as a compliant transport file, checked it against its metadata spec, and packaged the programs into a single reviewable text artifact. That is the shape of an all-R submission — admiralxportrmetacore/metatoolspkglite, the same tools this series taught one at a time, run here as one thread. The only thing separating this from a real deliverable is scale: more datasets (ADAE, ADTTE, ADLBC), the tables and figures, the Define-XML, and the reviewer’s guides — every one of them following this identical build → export → check → package pattern.

The R Consortium pilots: an all-R submission the FDA reviewed

This pipeline is not a teaching abstraction. The R Consortium — a neutral, non-profit industry body — ran a series of submission pilots: public, fully R-based test submissions to the FDA, built to prove the workflow end to end and to give the industry a working example to follow.

The Pilot 3 submission covered a full study package — ADaM analysis datasets (ADSL, ADAE, ADTTE, ADLBC, ADADAS) and the TLF (tables, listings, and figures) — assembled from SDTM, packaged into eCTD format, with every artifact made public. Its timeline is a matter of record in the R Consortium’s announcement:

Milestone Date
Initial submission via the FDA eCTD gateway August 28, 2023
Updated re-submission April 19, 2024
FDA final response letter August 8, 2024

The pilots were built on the pharmaverse toolchain — the same admiral, metacore/metatools, xportr, and pkglite you just used (the R Consortium describes the package bundled with pkglite and “various open-source R packages”; the full working code is public in the pilot 3 repository and the submission-to-FDA repository).

What this does — and does not — mean

Be precise here, because it is the whole credibility of the exercise:

  • What is true. The FDA reviewed and successfully completed its review of a fully R-based submission, and issued a favourable final response. That demonstrates the pipeline is feasible and acceptable, and it leaves a public working example future submissions can follow. It was an FDA–industry collaboration through the neutral R Consortium.
  • What is not true. This is not “R is FDA-approved,” “endorsed,” or “certified,” and the FDA does not “recommend R.” The FDA is software-agnostic: it reviews submissions, and does not bless a programming language. The pilots prove that an R-based submission can be prepared, delivered, and successfully reviewed — feasibility, not endorsement.

That distinction is not pedantry. Overclaiming here is the single most common way clinical R content loses credibility with the people who actually run submissions.

🟢 With an AI agent

Ask Prova “Walk me through an all-R FDA submission end to end — build an ADSL with admiral, export a compliant XPT with xportr, check it against a metacore spec with metatools, and bundle the programs with pkglite — and explain what the R Consortium pilots actually proved.” — it answers grounded in this pillar’s lessons, with runnable pharmaverse code you can try on public data. The runtime is the judge. Ask Prova →

Common issues

The date reads back as a number, not a Date. You set type = "date" in the export spec. xportr treats type = "date" as a character type and corrupts a real Date column on write. Export any ADaM date as type = "numeric" with format = "DATE9." (and run xportr_format() in the pipeline) — the value stays numeric on disk and read_xpt() restores it as a proper Date, as TRTSDT does in Stage 2.

SAFFL comes out NA for undosed subjects. derive_var_merged_exist_flag() sets the flag to its true_value where the condition matches, but subjects with no matching record get missing_value and subjects present-but-failing get false_value — both default to NA. A Y/N population flag must never be NA, so pass false_value = "N", missing_value = "N" explicitly, as Stage 1 does. Then every subject is Y or N.

The controlled-terminology check “fails” and you assume the tool is broken. A check_ct_col() warning is the check working — it found a data value that is not in the spec’s codelist ("Screen Failure" in TRT01P, in Stage 3). The fix is in the data or the spec, not the tool: null the analysis-treatment variable for screen failures, or confirm the value belongs and add it to the codelist. A conformance check that never flags anything is not checking.

Frequently asked questions

No — and the framing matters. The FDA is software-agnostic: it reviews submissions, not programming languages, so there is no such thing as an “FDA-approved” language. What the R Consortium pilots showed is that a fully R-based submission can be prepared, delivered in eCTD format, and successfully reviewed by the FDA — that is feasibility and acceptability, a public working example, not endorsement or certification.

A complete, public test submission emulating a full study package: ADaM analysis datasets (ADSL, ADAE, ADTTE, ADLBC, ADADAS) and the TLF (tables, listings, and figures), derived from SDTM and packaged into eCTD format. It was submitted through the FDA eCTD gateway in August 2023, re-submitted in April 2024, and received a final FDA response in August 2024. All of the code is public in the pilot 3 repository.

No. XPT (SAS Transport version 5) is an open, published exchange format — you do not need SAS to write one. In R, xportr applies a metadata spec and writes a compliant v5 file (calling haven::write_xpt() under the hood), and reads it back with haven::read_xpt(). Stage 2 above writes and re-reads a genuine v5 .xpt with no SAS involved.

Not with the packages in this pipeline. metacore reads a Define-XML into a structured object and metatools checks data against it, but neither writes a define.xml. Generating the file is done by other tooling — Pinnacle 21 or the open-source defineR package — as covered in the Define-XML lesson. Be precise about that boundary: metacore is a reader and a checker, not a generator.

The R Consortium’s Submissions Working Group publishes everything on GitHub. Start with the submissions working group hub, then the pilot 3 ADaM repository and the submission-to-FDA repository; the pilot 1 repository shows the earlier lineage. All of the datasets, programs, and packaging are public.

Test your understanding

Extend the pipeline. Take the exported dataset from Stage 2, add RACE to the variable spec (it is already a column in sub), re-run the xportr export with strict_checks = TRUE, then run check_ct_col() on RACE against the metacore spec. Does RACE pass its controlled-terminology check, and what have you just proved about a spec-driven pipeline?

RACE is already in sub and in the Stage 2 spec, so the export needs no new column — the exercise is to run the check on it. Load the metacore spec exactly as Stage 3 does (load() the .rda, select_dataset(..., verbose = "silent")), then call get_bad_ct(back, adsl_mc, RACE). character(0) means every value is in the codelist.

library(xportr); library(haven); library(metacore); library(metatools)

# `sub` and `var_spec` already include RACE (Stage 2). Re-export with strict checks:
xpt_path <- file.path(tempdir(), "adsl.xpt")
sub |>
  xportr_metadata(var_spec, "ADSL") |> xportr_type() |>
  xportr_length(length_source = "metadata") |> xportr_label() |>
  xportr_order() |> xportr_format() |> xportr_df_label(df_spec) |>
  xportr_write(xpt_path, strict_checks = TRUE)
back <- read_xpt(xpt_path)

# Load the spec and check RACE against its controlled terminology.
e <- new.env(); load(metacore_example("pilot_ADaM.rda"), envir = e)
adsl_mc <- select_dataset(get("metacore", envir = e), "ADSL", verbose = "silent")

get_bad_ct(back, adsl_mc, RACE)   # character(0) -> RACE is clean

RACE passes — get_bad_ct() returns character(0), so every race value is in the spec’s codelist, just as SEX was. That is the payoff of a spec-driven pipeline: one metacore spec checks every variable the same way, cleanly separating the columns that conform (SEX, RACE) from the one that drifted (TRT01P), so you know exactly what to fix before a reviewer sees it.

A. It makes xportr_write() faster by skipping the metadata application. B. It promotes any v5 conformance violation (a too-long name, label, or value) to a hard error that aborts the write, so a non-conformant .xpt cannot ship — whereas the default FALSE only warns and writes the file anyway. C. It automatically generates the Define-XML metadata alongside the transport file.

B. strict_checks = TRUE turns every conformance finding into a hard error and refuses to write, which is exactly what a submission needs — a violation should stop the pipeline, not decorate it with a warning you might scroll past. A is wrong: the checks still run (that is the point), and the metadata is still applied. C is wrong: xportr never writes a define.xml — that is metacore’s domain to read, and Pinnacle 21 / defineR to generate.

Conclusion

This is where the submission-packaging series comes together. One dataset, four stages: build the ADSL from SDTM with admiral, export it to a compliant XPT v5 with xportr and strict_checks = TRUE, check it against a metacore spec with metatools — where the controlled-terminology check earned its keep by catching a real drift — and package the build programs into one ASCII text artifact with pkglite. Scaled up across every analysis dataset, the tables and figures, the Define-XML, and the reviewer’s guides, that identical pattern is an all-R submission — the exact workflow the R Consortium pilots delivered to the FDA and made public. Hold the framing precisely: the pilots prove the pipeline is feasible and acceptable, a working example anyone can follow — never that a language is “approved.” Build it right, stage by stage, and the package a regulator receives is one you can stand behind.

Note

This lesson is reproducible: every result on this page was produced by the code shown — copy any block and run it to reproduce them. The runtime is the judge.

References

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Reproduce an {R} {Submission} to the {FDA} {End} to {End:}
    {The} Pharmaverse {Capstone}},
  date = {2026-07-01},
  url = {https://www.datanovia.com/learn/pharma-clinical/07-submission-packaging/reproduce-fda-r-pilot},
  langid = {en}
}
For attribution, please cite this work as:
“Reproduce an R Submission to the FDA End to End: The Pharmaverse Capstone.” 2026. July 1. https://www.datanovia.com/learn/pharma-clinical/07-submission-packaging/reproduce-fda-r-pilot.