One-command assembly of a submission folder of .xpt transport files via {xportr}, with a transport-layer gate that runs before you write and a manifest proving what shipped.

xportr writes an excellent transport file. But a submission is not one transport file — it is a folder of them, correctly named and split, with a manifest as evidence of what was sent. Today that assembly is a hand-run checklist people get wrong under deadline. submitpack makes it one call.

Scope

What it does. Takes data frames plus a metadata spec and writes a folder of SAS v5 transport files; splits anything over the per-file ceiling; runs nineteen transport-layer checks first and refuses to write on any error-severity finding; returns a manifest of file names, sizes, MD5 checksums and record counts read back off disk.

What it does not do.

  • It does not generate Define-XML. write_submission(define_xml = ) is a hook. Give it the path of a Define-XML file you already have and it is copied into the folder, or give it a function(spec, path) and it is called after the transport files are written. define21::write_define() fits that signature — there is a guarded integration test for exactly that — but submitpack neither imports nor requires it, and works completely without it.
  • It is not a conformance checker. See “Limits” below.
  • It assembles datasets only. No SDRG/ADRG, no programs folder, no eCTD structure.
  • The submission_spec is transport metadata, not a Define-XML model. No codelists, value-level metadata, methods, comments, origins or where-clauses.

Install

install.packages(
  "https://clincoder.cloud/submitpack/submitpack_0.0.1.tar.gz",
  repos = NULL, type = "source"
)

Quick start

library(submitpack)

datasets <- data.frame(dataset = "DM", label = "Demographics", class = "SDTM")
variables <- data.frame(
  dataset = "DM",
  variable = c("USUBJID", "AGE", "SEX"),
  label = c("Unique Subject Identifier", "Age", "Sex"),
  type = c("character", "numeric", "character"),
  length = c(20, 8, 1),
  format = NA_character_,
  order = 1:3
)
spec <- submission_spec(datasets, variables, study = list(study_id = "CDISCPILOT01"))

dm <- data.frame(
  USUBJID = c("CDISC-001", "CDISC-002"),
  AGE = c(63, 35),
  SEX = c("M", "F")
)

# Would this get rejected? Check before you write.
check_submission(list(DM = dm), spec)

# Run the xportr pipeline (type, length, label, format, order, write),
# split anything over split_mb, and get the manifest back.
man <- write_submission(list(DM = dm), spec, path = "submission")
man

# Independently prove what's actually on disk.
submission_manifest("submission")

Every check it enforces

Nineteen, and this is the whole list. Each row check_submission() returns names the check that produced it.

Check Severity What it catches
dataset_name_length error Dataset name over 8 characters
variable_name_length error Variable name over 8 characters
dataset_name_chars error Dataset name outside [A-Za-z_][A-Za-z0-9_]*
variable_name_chars error Variable name outside [A-Za-z_][A-Za-z0-9_]*
dataset_label_length error Dataset label over 40 characters
variable_label_length error Variable label over 200 characters
duplicate_dataset_name error Dataset declared twice
duplicate_variable_name error Variable declared twice, in the spec or in the real data frame’s column names
length_too_short error Declared length shorter than the longest observed value, counted in bytes — a 4-character UTF-8 value needing 5 bytes is caught
control_character error A C0 control byte (0x01–0x1F) or DEL in a label or a value
non_ascii_metadata warning Byte above 0x7F in a dataset or variable label
non_ascii_data warning Byte above 0x7F in a character value
split_variable_set error Two parts of a split dataset carry different variables
split_variable_order error Two parts hold the same variables in a different order
split_length_consistency error A variable’s declared length differs between parts
split_label_consistency error A variable’s label differs between parts (first 40 bytes, all a v5 NAMESTR holds)
split_part_name_length error A part file name over 8 characters
split_part_naming warning Part names that are not <dataset><n>
file_size warning Estimated written size over max_file_mb (default 5000)

Split parts can be given as a list of data frames or as paths to already-written .xpt files. For files, the declared length is read out of the transport file’s NAMESTR records directly, because haven::read_xpt() trims the padding away and cannot tell you what the file declared.

write_submission() runs all of this automatically and aborts, before touching disk, on any "error"-severity violation.

Limits

  • Not a conformance checker. submitpack does not check controlled terminology, codelist membership, Define-XML conformance or even presence, cross-dataset referential integrity (USUBJID in AE existing in DM), required or expected variables for a domain, variable order against the CDISC IG, ISO 8601 date formats, --SEQ uniqueness, value-level metadata, SUPPQUAL structure, or any of the several hundred Pinnacle 21 rules. Zero violations means clean against the table above and nothing more. Run Pinnacle 21 (or p21bridge) for conformance.
  • The 5000 MB per-file ceiling is a default, not a tracked fact. It is the 5 GB per transport file that both FDA (Study Data Technical Conformance Guide) and PMDA publish. submitpack does not track guidance revisions — set max_file_mb yourself if the guidance you are working to differs, or NULL to skip the check.
  • file_size is computed, not measured. XPT v5 is a fixed layout with no compression, so the size follows from the declared lengths and row count; the estimator is asserted byte-for-byte against real CDISCPILOT01 files in the test suite. It is still arithmetic on the spec, so a spec that does not match the data gives a size that does not match the file.
  • Split parts written by write_submission(split_mb = ) are consistent by construction (they come from one data frame), so the split checks exist for parts you did not write here — legacy files, another vendor’s, a previous run.
  • submission_manifest() is one level deep and the checksum is MD5: an integrity check, not a tamper-proof signature. It is also not reproducible across runs — a v5 transport file stamps its own creation date and time into the header, so writing the same data twice gives the same size and a different checksum.
  • Only "character" and "numeric" types. Dates are the ISO 8601 strings or the numeric SAS dates you hand in; no date semantics are applied.

Design

  • submission_spec() — the metadata object driving the write. Its variables table uses the exact column names xportr expects (dataset, variable, label, type, length, format, order), so it can be handed straight to xportr::xportr_type() and friends.
  • write_submission() — runs the xportr pipeline per dataset in the correct order, writes the folder, splits oversized files via xportr::xportr_write(max_size_gb = ), and returns the manifest.
  • check_submission() — the pre-write gate described above.
  • submission_manifest() — reads a submission folder back off disk (file name, size, MD5 checksum, record count, variable count) — the artefact that proves what shipped, independent of what the writer thinks it wrote.

Tested against

Real CDISCPILOT01 (LZZT) data, shipped in inst/extdata: the dataset and variable metadata extracted from the study’s own SDTM and ADaM define.xml (32 datasets, 728 variables), plus the real DM (306 × 25) and ADSL (254 × 48) data. Writing DM from that metadata reproduces the study’s own dm.xpt at exactly 110,800 bytes.

License

MIT (c) Bhanoji Duppada