← Back to blog

21 CFR Part 11-compliant immunogenicity analysis in R with regulog

regulog 21 CFR Part 11 Immunogenicity Vaccine statistics Audit trail

Vaccine immunogenicity analyses supporting regulatory submissions operate under 21 CFR Part 11: every analytical decision, data access event, and result must be traceable, time-stamped, and tamper-evident. In most organisations, this means maintaining a separate paper audit trail that is manually assembled after the analysis — disconnected from the code, unverifiable, and a persistent inspection risk.

regulog solves this at the source. Every step of the analysis writes a hash-chained log entry in real time — who did what, when, and why — producing a tamper-evident audit trail that is a direct artefact of the analysis itself, not a retrospective reconstruction of it.

This walkthrough covers a complete immunogenicity analysis for a Phase III RSV vaccine trial: GMT computation, seroconversion rates, persistence analysis, outlier review, electronic sign-off, and audit trail export.

The trial

RSV-VAC-301 is a randomised, double-blind, placebo-controlled Phase III trial of a novel adjuvanted subunit RSV vaccine in adults aged 60 and older. The primary immunogenicity endpoint is the geometric mean titre (GMT) ratio of RSV-neutralising antibodies at Day 29 relative to placebo. Secondary endpoints include seroconversion rate (≥4-fold rise from baseline) and GMT persistence at Day 181.

Setup

library(regulog)

# Simulated CDISC-structured ADIS dataset
# 300 subjects, 3 visits (Day 1, 29, 181)
# RSV neutralising antibody titres (IU/mL)
# Log-normal distribution, 4% missingness
set.seed(2026)

Opening the audit session

regulog_init() creates the session object and writes the genesis record immediately — this starts the SHA-256 hash chain that links every subsequent log entry. Study-level context (SAP version, data cut, analysis set) is captured as the first logged note.

log <- regulog_init(
  app     = "RSV-VAC-301-primary-immunogenicity",
  version = "1.0.0",
  user    = "jsmith",
  path    = "logs/audit_RSV301_primary_v1.rlog"
)

log_note(log,
  "Primary immunogenicity analysis per SAP v2.0, Section 5.1. Protocol:
   RSV-VAC-301. Data cut: 2026-05-15. Analysis set: immunogenicity
   per-protocol population (PPROTFL = Y)."
)

log
# 
#   App:     RSV-VAC-301-primary-immunogenicity v1.0.0
#   User:    jsmith
#   Entries: 1
#   Path:    logs/audit_RSV301_primary_v1.rlog

Logging data access

Under 21 CFR Part 11 §11.10(e), every access to audit-relevant data must be logged. with_log() provides a local read() binding scoped to the block — every read inside it is logged automatically, capturing the resolved file path, row count, and column count:

with_log(log, {
  adsl <- read(read.csv, "data/adsl.csv")
  adis <- read(read.csv, "data/adis.csv")
})

Applying the analysis population

The primary analysis uses the per-protocol population (PPROTFL = Y). Every subject excluded from the analysis is documented — not just the count, but individual exclusion notes for missing-data cases:

# Filter to per-protocol population
adis_pp <- adis |> filter(PPROTFL == "Y")

log_action(log,
  action = "apply_pp_population",
  object = "RSV-VAC-301 per-protocol population",
  reason = sprintf(
    "Restricted to per-protocol population per SAP Section 3.2. ITT: %d
     subjects | PP: %d subjects | Excluded: %d (protocol deviations)",
    n_distinct(adis$USUBJID),
    n_distinct(adis_pp$USUBJID),
    n_distinct(adis$USUBJID) - n_distinct(adis_pp$USUBJID)
  )
)

Handling missing Day 29 titres

Subjects with missing primary timepoint assessments are excluded from the primary GMT analysis. Each exclusion is individually documented with log_note():

miss_d29 <- adis_pp |> filter(AVISITN == 29, is.na(AVAL))

log_note(log, sprintf(
  "Missing data review — Day 29 missing titres: %d subjects (%.1f%%) —
   excluded per SAP", nrow(miss_d29),
  nrow(miss_d29) / nrow(filter(adis_pp, AVISITN == 29)) * 100
))

# Document each excluded subject individually
for (subj in miss_d29$USUBJID) {
  log_note(log, sprintf(
    "Subject excluded (missing data) — USUBJID %s: Day 29 titre missing",
    subj
  ))
}

Primary analysis: GMT ratio

GMTs are computed as the back-transformed mean of log2-titres. The GMT ratio (Vaccine/Placebo) is tested via a two-sample t-test on the log2 scale:

adis_primary <- adis_pp |> filter(AVISITN == 29, !is.na(AVAL))

gmt_d29 <- adis_primary |>
  group_by(TRT01P) |>
  summarise(
    n   = n(),
    gmt = 2^mean(log2(AVAL)),
    gmt_lo = 2^(mean(log2(AVAL)) - qt(0.975, n()-1) * sd(log2(AVAL)) / sqrt(n())),
    gmt_hi = 2^(mean(log2(AVAL)) + qt(0.975, n()-1) * sd(log2(AVAL)) / sqrt(n()))
  )

ttest     <- t.test(log2(AVAL) ~ TRT01P, data = adis_primary)
gmt_ratio <- 2^(-diff(ttest$estimate))
ratio_ci  <- 2^(-rev(ttest$conf.int))

log_action(log,
  action = "compute_gmt_ratio",
  object = "GMT ratio (Vaccine/Placebo)",
  reason = sprintf(
    "Computed per SAP Section 5.1. GMT ratio = %.2f (95%% CI: %.2f, %.2f), p %s",
    gmt_ratio, ratio_ci[1], ratio_ci[2],
    ifelse(ttest$p.value < 0.001, "< 0.001", sprintf("= %.3f", ttest$p.value))
  )
)

Seroconversion analysis

A seroconverter is defined as a subject with a ≥4-fold rise from baseline. The definition is logged before computing the result — establishing what was pre-specified:

log_note(log,
  "Seroconversion defined as >= 4-fold rise from baseline (pre-specified per SAP)"
)

sc_rates <- adis_pp |>
  filter(AVISITN == 29, !is.na(AVAL), !is.na(BASE)) |>
  mutate(SEROCONV = as.integer(RATIO >= 4)) |>
  group_by(TRT01P) |>
  summarise(
    n    = n(),
    n_sc = sum(SEROCONV),
    rate = mean(SEROCONV)
  )

log_action(log,
  action = "compute_seroconversion",
  object = "Seroconversion rates",
  reason = sprintf(
    "Computed per SAP Section 5.2. %s",
    paste(sprintf("%s: %d/%d (%.1f%%)", sc_rates$TRT01P, sc_rates$n_sc,
                  sc_rates$n, sc_rates$rate * 100), collapse = " | ")
  )
)

Outlier review

A key audit requirement: flagged-but-retained observations must be documented with the decision rationale. log_note() makes this natural:

# Flag extreme observations (|z| > 3 on log2 scale)
outlier_review <- adis_primary |>
  group_by(TRT01P) |>
  mutate(
    z_score = (log2(AVAL) - mean(log2(AVAL))) / sd(log2(AVAL)),
    outlier = abs(z_score) > 3
  ) |> ungroup()

log_note(log, sprintf(
  "Outlier screen (|z| > 3, log2 scale): %d flagged — retained per SAP
   (no clinical basis for exclusion; sensitivity analysis planned)",
  sum(outlier_review$outlier)
))

# Log each flagged subject individually
for (i in which(outlier_review$outlier)) {
  log_note(log, sprintf(
    "Outlier flagged — USUBJID %s: z = %.2f — retained, flagged for sensitivity",
    outlier_review$USUBJID[i], outlier_review$z_score[i]
  ))
}

Electronic sign-off

Under 21 CFR Part 11 §11.100 and §11.200, electronic signatures must include the signatory's name, the date and time, and the meaning of the signature. log_signature() resolves the signer identity automatically from the session user set at regulog_init() — it cannot be overridden at signing time — and records the number of entries covered without any extra input:

log_signature(log,
  "I confirm that this analysis was conducted in accordance with SAP v2.0
   and that the results presented are accurate and complete to the best
   of my knowledge."
)

Verifying the hash chain

Any post-hoc modification to any log entry breaks the SHA-256 chain. verify_log() recomputes the full chain from the first entry and reports the first broken link — or confirms the chain is intact:

verify_log(log)
# regulog: Log intact: 34 entries, chain unbroken

Exporting the audit trail

export_audit_trail(log,
  format = "csv",
  signed = TRUE,
  path   = "outputs/audit_trail_RSV301_primary_v1.csv"
)
# regulog: exported 34 row(s) to outputs/audit_trail_RSV301_primary_v1.csv

The exported CSV contains: entry ID, timestamp (UTC), analyst, entry type, action, reason, and the SHA-256 hash linking each entry to its predecessor. With signed = TRUE, every row is also stamped with chain_intact and verified_at from a fresh verification run at export time. This file is the submission-ready audit trail — self-contained, independently verifiable, and directly connected to the analysis that produced the results.

What the audit trail proves

The complete record covers:

The SHA-256 chain means any modification after the fact — to any entry — is detectable without reference to the original system. This satisfies the tamper-evidence requirement of 21 CFR Part 11 §11.10(e) and EU Annex 11 Clause 9 directly from the analysis code, without any parallel documentation process.

Using regulog in practice

The pattern above generalises to any regulated R analysis. The key discipline: log before and after every consequential decision, not just at the end. Decisions that feel routine in isolation — which population flag to use, how to handle a missing timepoint, whether to retain an outlier — are exactly the decisions regulators ask about. regulog makes the answer permanently on record.

For integration with data provenance tracking (row-level lineage across the derivation pipeline), pair regulog with lineager. For reproducibility certification of the analysis environment, pair with reproducr. All three packages are designed to work together.


Ndoh Penn is a biostatistician based in Antwerp, Belgium, and the author of regulog, reproducr, and bayprior. Questions or corrections — hello@reprostats.org or open an issue on GitHub.