AI for Biometrics

SAS/R Trial-Monitoring Automation — the coverage you can ship today

A deterministic, no-AI companion to the Claude + Local and M365 Copilot + Local pipelines. It automates trial monitoring using only validated SAS and/or R, inside the existing environment — so a covering team and the manager stay informed while the lead is away, with no new tools, no new vendor, and no PHI egress.

Read the one honest framing first. This reliably DETECTS and DELIVERS pre-specified facts — counts, thresholds, deltas, reconciliation breaks, run failures — to a human, reproducibly. It does not triage, narrate, or judge clinical meaning; the medical monitor and humans own every decision. That triage layer is exactly the seam the AI hybrid fills later. Necessary, not sufficient — and that is the point: ship this now, layer AI on top when it is approved.

The 60-second version

  1. A scheduler (OS Task Scheduler / cron) fires a version-controlled SAS or R program in unattended batch mode, under a service account.
  2. The program ingests the latest validated extract, runs deterministic checks, and renders a report (SAS ODS / R Markdown).
  3. It notifies people in two tiers: a scheduled digest (daily to the cover, weekly one-pager to the manager) and an immediate threshold alert on any KRI / QTL / DLT / SAE flag.
  4. Every run emits a heartbeat with a data-freshness timestamp — so silence is never assumed.

See it end-to-end in Coverage while on leave — a full month on Study CP-101.

Why this is the lowest-risk of the three pipelines

It adds nothing new: no LLM, no model to validate, no vendor, no data leaving the validated boundary. Every reported number comes from the same double-programmed, QC’d programs the team already uses. So there is no new approval gate between today and go-live — only the team’s normal program validation / change control.

The deterministic loop — scheduler → program → report → notify

Every component already exists and is qualified inside a validated SAS/R environment. Pick whichever scheduler is already approved in your shop — do not introduce a new orchestrator just for this.

Scheduler (the clock)Windows Task Scheduler / cron — runs under a service account, survives reboots, logs its own history
↓
SAS / R program (batch, unattended)ingest latest extract → data-freshness gate → deterministic checks → render report
↓     ↓
ReportSAS ODS (PDF/RTF/HTML) or R Markdown / Quarto / officer / flextable
Notify (2 tiers)digest + threshold alert via SAS EMAIL engine or R Microsoft365R/blastula

Scheduling options (use what is already validated)

OptionWhat it isWhen
SAS batch via OS scheduler (the validated default)sas -sysin monitor.sas -log dated.log -batch -noterminal, fired by Task Scheduler / cron. Pure Base SAS 9.4 behaviour that has run validated pipelines for decades.The lowest-governance, most defensible choice. Start here.
cronR / taskscheduleR (R)R-native wrappers to register Rscript jobs (cronR::cron_add(), taskscheduleR::taskscheduler_create()); pin packages with renv for byte-reproducibility.When the report is authored in R (ggplot2 / gt / safetyGraphics eDISH).
SAS 9.4 EG / Studio / Stored ProcessesExport a flow to a deployed job run by Platform/LSF or Task Scheduler; Stored Processes for parameterized server-side runs.Legacy-but-valid on a 9.4 stack.
The single most common silent-failure cause: running under the lead’s personal login. Use a dedicated service / functional account, and confirm before departure that it has the SAS licence, network-share access, and SMTP relay rights. A personal account’s credentials/VPN go idle on leave — and the job dies the moment they do.

Scheduled data ingestion

The job’s first step reads the latest validated extract deterministically: SDTM/ADaM .sas7bdat/.xpt (libname xport, haven::read_xpt()), EDC CSV exports, a read-only DB view to the safety/IXRS database (SAS/ACCESS, DBI+odbc). A small macro resolves the newest dated file so “latest export” is unambiguous. It is only as fresh as the upstream cadence — document each source’s refresh frequency in the report header (see the freshness gate).

Everything above is the design. Here is the actual program that runs it: the one file you edit per study, then the nightly job you schedule. Plain, double-programmed SAS you can read top to bottom.

The one file you edit per study: tm_config.sas

SAS · 39 lines · the real shipped file, in full — nothing elided.

tm_config.sas is the single per-study configuration file for the TRIALMON monitoring library, the deterministic SAS/R monitor that watches a study's data feeds and raises safety and operations flags. You copy this one file per study and edit only its %let values, then %include it before monitor_driver.sas so all study-specific facts (study id, filesystem paths, mail server, recipients, the medical monitor, the feed list, and the pre-specified thresholds) live here instead of being hard-coded into the driver. The header comment states that the file is intended to be included ahead of monitor_driver.sas so the driver and the macros it calls can read these global macro variables to know where the data is, who to notify, and what limits to test against. Nothing in this file computes a result or makes a decision: it only supplies constants that the validated SAS/R code downstream consumes, so a second study or a rotating medical monitor is a one-file change, not a code edit. Every reported number comes from the validated SAS/R tools, not from this file, which merely holds the pre-specified cut-points those tools apply.

/*====================================================================================
  tm_config.sas  -  per-study constants for the TRIALMON monitor. Copy + edit per study;
  %include before monitor_driver.sas. Keeps study id, paths, recipients, the medical
  monitor, feeds, and thresholds OUT of the driver so a second study (or a rotating MM)
  is one file, not a code edit.
====================================================================================*/
%global CFG_STUDY CFG_ROOT CFG_SMTP CFG_STATE CFG_SDTM CFG_ADAM CFG_SAFE CFG_IXRS
        CFG_SUPPORT CFG_BACKUP CFG_ALIAS CFG_PM CFG_MGR CFG_MM_NAME CFG_MM_PHONE
        CFG_FEEDS CFG_MAXAGE CFG_ALTM CFG_BILM CFG_DOSEVAR CFG_ENROLL_KRI CFG_QRY_AGE;

/* --- study & paths --- */
%let CFG_STUDY  = CP101;
%let CFG_ROOT   = /opt/trialmon/cp101;
%let CFG_SMTP   = smtp.internal.example.com;
%let CFG_STATE  = /opt/trialmon/cp101/state;       /* persistent: seen-flags + heartbeat */
%let CFG_SDTM   = /data/cp101/sdtm;
%let CFG_ADAM   = /data/cp101/adam;
%let CFG_SAFE   = /data/cp101/safety;
%let CFG_IXRS   = /data/cp101/ixrs;

/* --- recipients & escalation (role aliases + a backup, never one inbox) --- */
%let CFG_SUPPORT= biostat.cover@example.com;
%let CFG_BACKUP = biostat.backup@example.com;       /* also the watchdog / fail-loud target */
%let CFG_ALIAS  = biostat-cover@example.com;
%let CFG_PM     = pm.cp101@example.com;
%let CFG_MGR    = manager@example.com;
%let CFG_MM_NAME= %str(Dr. R. Okafor);
%let CFG_MM_PHONE= +1-555-0100;

/* --- feeds & freshness --- */
%let CFG_FEEDS  = adam.adlb adam.adeg adam.adae safe.sae ixrs.rand;
%let CFG_MAXAGE = 26;                                /* hours before a feed is flagged stale */

/* --- thresholds (pre-specified from the protocol/SAP) --- */
%let CFG_ALTM     = 3;       /* Hy's-Law ALT/AST multiple of ULN (strict >)  */
%let CFG_BILM     = 2;       /* Hy's-Law total bilirubin multiple of ULN     */
%let CFG_DOSEVAR  = COHORT;  /* the DOSE-LEVEL key for the 3+3 DLT tally; set to DOSELVL if cohort != dose level */
%let CFG_ENROLL_KRI = 0.80;  /* enrollment KRI floor (fraction of plan)      */
%let CFG_QRY_AGE  = 30;      /* open-query aging threshold (days)            */

Walkthrough, part by part

File header comment. The opening comment block states the file's contract: these are per-study constants for the TRIALMON monitor, you copy and edit the file per study, and you %include it before monitor_driver.sas. It explains the design intent, keeping study id, paths, recipients, the medical monitor, feeds, and thresholds OUT of the driver so that onboarding a second study or swapping a rotating medical monitor (abbreviated MM in the comment) is a single edited file rather than a change to the monitoring logic. This matters because it enforces the separation between validated code (the driver) and study-specific configuration (this file), which keeps the monitor's tested behavior stable across studies.

%global declaration of all CFG_ variables. A single %global statement declares every configuration macro variable the file will set: CFG_STUDY, CFG_ROOT, CFG_SMTP, CFG_STATE, CFG_SDTM, CFG_ADAM, CFG_SAFE, CFG_IXRS, CFG_SUPPORT, CFG_BACKUP, CFG_ALIAS, CFG_PM, CFG_MGR, CFG_MM_NAME, CFG_MM_PHONE, CFG_FEEDS, CFG_MAXAGE, CFG_ALTM, CFG_BILM, CFG_DOSEVAR, CFG_ENROLL_KRI, and CFG_QRY_AGE. Declaring them global (rather than letting them default to local scope) makes the values available to the driver and every macro it calls after this file is included. Because this file is included at the top, the global scope is what lets the whole monitoring cycle read one consistent set of constants.

Study identifier and filesystem paths. This block sets the study key and the locations the monitor reads and writes: CFG_STUDY is the study id (CP101), CFG_ROOT is the monitor's base directory (/opt/trialmon/cp101), and CFG_SMTP is the mail server used to send notifications. CFG_STATE points to a persistent directory that, per its inline comment, holds seen-flags and the heartbeat, which is how the monitor remembers what it already reported and records that it ran between cycles. CFG_SDTM, CFG_ADAM, CFG_SAFE, and CFG_IXRS are the input data paths for SDTM, ADaM, safety, and IXRS/randomization sources. These are the concrete values a biostatistician must change first when standing up a new study, since every downstream read and write resolves against them.

Recipients and escalation. This block defines who gets notified, and its inline comment states the rule: use role aliases plus a backup, never one inbox. CFG_SUPPORT is the covering biostatistics address, CFG_BACKUP is a second address that its comment identifies as the watchdog and fail-loud target, CFG_ALIAS is an additional alias address (biostat-cover@example.com), and CFG_PM and CFG_MGR are the project manager and manager addresses. Routing to roles and a backup rather than one person matters because monitoring must reach someone even during leave or turnover, and the fail-loud backup ensures that if the monitor itself has a problem, a human is told rather than the failure going silent. A human at these addresses owns every decision that follows a flag: the monitor only delivers the notification.

Medical monitor contact. CFG_MM_NAME holds the medical monitor's name, set with %str() so the value (Dr. R. Okafor) is stored as literal text, and CFG_MM_PHONE holds that person's phone number (+1-555-0100). These are carried in the config so that a safety alert can name and reach the correct clinician, and so a rotating medical monitor is updated in this one file rather than in the driver. The %str() wrapper matters practically because it masks the periods in the name from the macro processor so the value is taken as a plain string.

Feeds and freshness. CFG_FEEDS lists the data feeds the monitor tracks as a space-delimited set of library-dot-dataset names: adam.adlb, adam.adeg, adam.adae, safe.sae, and ixrs.rand. CFG_MAXAGE is the freshness limit in hours (26), and per its comment a feed older than this is flagged as stale. Together they let the driver iterate over exactly the expected feeds and raise an operations flag when any one has not updated recently, which catches a broken or delayed data delivery before it silently starves the safety checks.

Hy's-Law liver thresholds. CFG_ALTM is set to 3 and CFG_BILM to 2, the pre-specified Hy's-Law multiples of the upper limit of normal for ALT/AST and for total bilirubin respectively, taken from the protocol and SAP as the block comment notes. The CFG_ALTM comment specifies a strict greater-than comparison, so the intended rule flags values that exceed 3x ULN rather than values that merely equal it; the CFG_BILM comment states the bilirubin multiple without repeating that qualifier. Holding these limits as named constants keeps the numeric cut-points pre-specified and auditable: the validated SAS/R code applies them to the data and computes the flag, and a clinician reviews any resulting signal.

Dose-level key for the 3+3 DLT tally. CFG_DOSEVAR names the variable that identifies dose level for the 3+3 dose-limiting-toxicity count, defaulting to COHORT. Its comment instructs you to set it to DOSELVL instead when cohort is not the same as dose level, which handles studies where those two concepts diverge. This matters because the DLT tally must group participants by the correct dose-defining key: choosing the wrong variable would count toxicities against the wrong dose grouping. The setting only tells the validated code which column to group by; the tally itself is computed by SAS/R and interpreted by the safety review team.

Enrollment KRI floor. CFG_ENROLL_KRI is set to 0.80, the enrollment key-risk-indicator floor expressed as a fraction of plan per its comment. When actual enrollment falls below 80 percent of the planned figure, the driver can raise an operations flag for under-enrollment. Keeping this as a pre-specified fraction rather than a hard-coded rule inside the driver lets each study set its own tolerance while the flag logic stays validated and unchanged.

Open-query aging threshold. CFG_QRY_AGE is set to 30, the age in days at which an open data query is treated as aged. The driver uses this to flag queries that have stayed open beyond the threshold, surfacing a data-cleaning backlog before it delays downstream deliverables. As with the other constants, the number lives here so it is study-configurable and traceable, while the counting and comparison are performed by the validated tool and acted on by a human.

The nightly monitoring job you schedule, every line: monitor_driver.sas

SAS · 72 lines · the real shipped file, in full — nothing elided.

monitor_driver.sas is the top-level SAS program you schedule to run unattended once a day for Study CP-101: it is the whole nightly monitoring job, top to bottom, orchestrating a fixed sequence (INGEST, freshness gate, data guards, deterministic checks, roll-up, digest, tier-2 alert, heartbeat). It is meant to run in batch under a service account (schtasks on Windows or cron 06:00 Mon-Fri on Linux), not a personal login, so it never depends on someone being logged in. Every number and every pass/fail decision here is produced by validated %tm_* macros in SAS; the driver only sequences them and routes output. It is designed to fail loud: a broken feed or a missing column surfaces as a red run rather than a silent green.

/*====================================================================================
  monitor_driver.sas  -  the daily unattended monitoring job (Study CP-101).
  Schedule in batch under a SERVICE account (not a personal login):
     Windows : schtasks /create /tn "CP101_monitor" /tr "run_monitor.bat" /sc DAILY /st 06:00 /ru SVC-BIOSTAT
     Linux   : 0 6 * * 1-5  /opt/jobs/run_monitor.sh    # sas -sysin monitor_driver.sas -batch
  run_monitor.bat: sas.exe -sysin monitor_driver.sas -log "logs\cp101_%date%.log" -nosplash -batch -noterminal
  Flow: INGEST -> FRESHNESS GATE -> GUARD -> checks -> roll-up -> DIGEST, + a tier-2
  ALERT on a de-duplicated RED finding, always closed by a HEARTBEAT. Fails LOUD.
====================================================================================*/
%include "/opt/trialmon/tm_macros.sas";
%include "/opt/trialmon/tm_config.sas";     /* per-study constants: paths, recipients, MM, thresholds, feeds */

/* 1. init (values come from tm_config.sas) ---------------------------------------- */
%tm_init(study=&CFG_STUDY, root=&CFG_ROOT, smtp=&CFG_SMTP, statelib=&CFG_STATE, backup=&CFG_BACKUP);

libname sdtm "&CFG_SDTM"; libname adam "&CFG_ADAM"; libname safe "&CFG_SAFE"; libname ixrs "&CFG_IXRS";

/* 2. FRESHNESS GATE first - stale-green is worse than red -------------------------- */
%tm_freshness(feeds=&CFG_FEEDS, tsvar=_loaddttm, maxage=&CFG_MAXAGE);
%tm_status(in=_tm_fresh);

/* 3. GUARD - assert expected columns exist, are numeric, pass range-sanity --------- */
%tm_guard(ds=adam.adlb_edish, numvars=alt_x ast_x tbl_x alp_x, sanity=(0<=alt_x<=400 and 0<=tbl_x<=60));
%tm_guard(ds=adam.adeg, numvars=QTCF DQTCF, sanity=(200<=QTCF<=700));

/* 4. deterministic checks ---------------------------------------------------------- */
%tm_chk_hyslaw(in=adam.adlb_edish, altmult=&CFG_ALTM, bilmult=&CFG_BILM);
%tm_chk_qtcf  (in=adam.adeg);
%tm_chk_ae    (in=adam.adae);
%tm_chk_dlt   (in=adam.adsl_dlt, dosevar=&CFG_DOSEVAR);
%tm_chk_enroll(in=adam.enroll_summary, kri=&CFG_ENROLL_KRI);
%tm_chk_queries(in=safe.queries, age_amb=&CFG_QRY_AGE);
%tm_chk_visits(in=adam.expected_visits);
%tm_chk_ixrs  (clin=adam.adsl, ixrs=ixrs.rand);

/* 5. roll worst severity into the run status --------------------------------------- */
%tm_status(in=_tm_hyslaw); %tm_status(in=_tm_qtcf); %tm_status(in=_tm_ae);
%tm_status(in=_tm_dlt);    %tm_status(in=_tm_enroll); %tm_status(in=_tm_vis); %tm_status(in=_tm_ixrs);

/* 6. tier-1 digest to the cover (ALWAYS sent - silence is never assumed) ------------ */
%tm_digest(title=CP-101 Daily Safety Digest,
           sections=_tm_fresh _tm_enroll _tm_ae _tm_dlt _tm_qtcf _tm_hyslaw _tm_qry _tm_vis,
           to=&CFG_SUPPORT, dest=pdf);

/* 7. tier-2 urgent alert ONLY on a NEW (de-duplicated, PARTICIPANT-keyed) RED finding ---
   sigkey includes the participant/dose so a SECOND participant's RED is never suppressed.     */
data _tm_red;
  set _tm_hyslaw(in=a) _tm_qtcf(in=b) _tm_dlt(in=c);
  length subj $40 sigkey $200;
  if a or b then subj = USUBJID; else if c then subj = strip(vvalue(&CFG_DOSEVAR));
  where sev="RED";
  sigkey = catx('|', "&TM_STUDY", coalescec(subj,''), flag);
run;
%tm_dedup(in=_tm_red, key=sigkey, out=_tm_red_new);

/* build the per-participant evidence packet, then alert (attaches it if it exists) ------ */
%tm_evidence(in=_tm_red_new, lab=adam.adlb_edish, out=&TM_OUT/evidence_&TM_RUNID..pdf);
%tm_alert(in=_tm_red_new, to=&CFG_SUPPORT, backup=&CFG_BACKUP, alias=&CFG_ALIAS, cc=&CFG_PM,
          mm_name=&CFG_MM_NAME, mm_phone=&CFG_MM_PHONE, evidence=&TM_OUT/evidence_&TM_RUNID..pdf);

/* 8. HEARTBEAT - records counted with a single, closed handle (no leak) ------------- */
%let _did=%sysfunc(open(adam.adae)); %let _n=0;
%if &_did %then %let _n=%sysfunc(attrn(&_did,nlobs)); %let _rc=%sysfunc(close(&_did));
%tm_heartbeat(records=&_n, watcher_to=&CFG_BACKUP);

/* Friday: weekly status pack + a numeric manager one-pager ------------------------- */
%if %sysfunc(weekday(%sysfunc(today())))=6 %then %do;
  %tm_digest(title=CP-101 Weekly Status, sections=_tm_enroll _tm_ae _tm_dlt _tm_qry _tm_vis, to=&CFG_SUPPORT &CFG_MGR, dest=pdf);
%end;

%put NOTE: [TRIALMON] run complete - status=&TM_STATUS rc=&TM_RC;
endsas;

Walkthrough, part by part

Header block and scheduling contract. The opening comment fixes how this job is meant to be launched: in batch, under a dedicated service account (SVC-BIOSTAT) rather than a personal login, on a daily schedule via schtasks on Windows or a weekday cron entry on Linux. It also records the exact wrapper invocation (sas.exe with -sysin, a dated -log, -nosplash -batch -noterminal) so the job runs headless and writes a per-day log. The stated flow is INGEST then FRESHNESS GATE then GUARD then checks then roll-up then DIGEST, plus a tier-2 alert on a de-duplicated RED finding, always closed by a heartbeat. This matters because the governance model depends on the job actually running every day under an account that outlives any individual, and on it failing loud rather than quietly skipping.

Macro and config includes. Two %include statements pull in the shared code and the per-study constants: tm_macros.sas defines every %tm_* macro used below, and tm_config.sas supplies the study-specific values (paths, recipients, the medical monitor, thresholds, feed names) as &CFG_* macro variables. Keeping constants in tm_config.sas means the driver itself is study-agnostic: you point it at a new study by swapping the config, not by editing this file. The validated logic lives in the included macros, so the driver stays a thin, auditable sequencer.

Step 1: initialization. %tm_init is called with the study id, root path, SMTP server, state library, and backup destination, all sourced from the &CFG_* variables just loaded. This establishes the run context (run id, output locations, status accumulators) that the rest of the program reads and updates. Because these values come from tm_config.sas, the same driver initializes cleanly for any configured study.

Library assignments. Four libname statements bind the SDTM, ADaM, safety, and IXRS input locations to the librefs sdtm, adam, safe, and ixrs using the configured paths. Every downstream check reads its data through these librefs, so this line is where the job's inputs are wired in. Pointing these at validated study datasets is what lets SAS own every number the job later reports.

Step 2: freshness gate. %tm_freshness checks each configured feed's load timestamp (_loaddttm) against a maximum allowed age (&CFG_MAXAGE) and writes its verdict to _tm_fresh, which %tm_status then rolls into the run status. This runs first on purpose: the comment states that stale-green is worse than red, so a feed that silently stopped updating must degrade the run before any check reads its data. Without this gate, downstream checks could pass on yesterday's data and give false reassurance.

Step 3: data guards. Two %tm_guard calls assert structural and range sanity before any statistical check runs. The first guards adam.adlb_edish, requiring the numeric variables alt_x ast_x tbl_x alp_x and enforcing that ALT and total bilirubin sit in plausible ranges; the second guards adam.adeg, requiring numeric QTCF DQTCF with QTcF bounded to 200-700. Guarding first means a renamed column, a character-typed number, or an out-of-range value halts loudly instead of quietly corrupting a check result. This is a determinism safeguard: the checks only run against data whose shape and ranges have been verified.

Step 4: deterministic checks. Eight %tm_chk_* macros run the study's monitoring rules, each reading a specific validated dataset: Hy's-law screening on adam.adlb_edish using configured ALT and bilirubin multiples, QTcF on adam.adeg, adverse events on adam.adae, DLT on adam.adsl_dlt keyed by the configured dose variable, enrollment KRI on adam.enroll_summary, query aging on safe.queries, expected-visit completeness on adam.expected_visits, and an IXRS reconciliation of adam.adsl against ixrs.rand. Each macro is a validated tool that computes its own numbers and assigns a severity; the driver does not compute or reconcile any value itself. These are deterministic SAS checks: given the same data and thresholds they always return the same flags, and a human, not this job, decides what to do about them.

Step 5: roll worst severity into run status. Seven %tm_status calls fold the per-check result datasets (_tm_hyslaw, _tm_qtcf, _tm_ae, _tm_dlt, _tm_enroll, _tm_vis, _tm_ixrs) into the overall run status. The roll-up takes the worst severity seen, so a single red check makes the whole run red regardless of how many others are green. This gives one authoritative run status without discarding the individual check outputs, which are still carried into the digest.

Step 6: tier-1 daily digest. %tm_digest builds the CP-101 Daily Safety Digest as a PDF and sends it to the configured support address, assembling sections in a fixed order from the freshness, enrollment, AE, DLT, QTcF, Hy's-law, query, and visit result datasets. The comment is explicit that this digest is ALWAYS sent, so silence is never assumed to mean all-clear: recipients get a report every day whether the run is green or red. This is the routine reporting channel, distinct from the urgent alert that follows.

Step 7a: build the de-duplicated RED finding set. A DATA step stacks the Hy's-law, QTcF, and DLT result datasets into _tm_red, keeps only rows where sev="RED", and constructs a participant-scoped signature key. For Hy's-law and QTcF findings it sets subj from USUBJID; for DLT it uses the formatted dose value, and sigkey is catx of the study, that participant/dose, and the flag. The key deliberately includes the participant so, as the comment notes, a second participant's RED is never suppressed by a first one already seen. %tm_dedup then compares sigkey against prior state and writes only genuinely new findings to _tm_red_new, so the same participant's same red does not re-page every day.

Step 7b: evidence packet and tier-2 alert. %tm_evidence assembles a per-participant evidence PDF for the new red findings from adam.adlb_edish, written to a run-stamped path under &TM_OUT. %tm_alert then sends the urgent tier-2 notification to support with a backup destination, alias, and PM on cc, carrying the medical monitor's name and phone from config, and attaching the evidence PDF if it was produced. Both calls are unconditional, but they are passed _tm_red_new, which is empty on a green day, so the channel notifies only on a new, de-duplicated, participant-keyed RED finding and does not spam repeats. The alert notifies humans; it does not decide or act, the medical monitor and team own the response.

Step 8: heartbeat. This block opens adam.adae with %sysfunc(open(adam.adae)), reads the observation count via attrn(&_did,nlobs) only if the open returned a nonzero handle, and immediately closes the handle with %sysfunc(close(&_did)) so no dataset handle leaks. %tm_heartbeat then records that count and pings the backup watcher address supplied as watcher_to=&CFG_BACKUP. The heartbeat is what proves the job actually ran to completion: if the daily heartbeat is missing, the watcher knows the job died rather than assuming all was well.

Friday weekly pack. An %if tests whether today is Friday (weekday(today())=6) and, only then, calls %tm_digest again to build the CP-101 Weekly Status PDF from the enrollment, AE, DLT, query, and visit sections, sending it to both support and the manager. This adds a lower-frequency rollup for leadership on top of the daily digest without changing the daily behavior on other weekdays. It reuses the same validated result datasets, so the weekly numbers are the same numbers SAS already produced during the run.

Completion note and endsas. A final %put NOTE: writes a tagged one-line summary to the log with the overall run status and return code (&TM_STATUS, &TM_RC), giving the batch log a single grep-able line that says how the run ended. endsas; then terminates the SAS session cleanly so the batch wrapper gets a definite exit. Together these make the run's outcome unambiguous to both the log reader and the scheduler.

The monitoring-checks catalog

A fixed battery of deterministic flags that run unattended and give the same answer every time on the same data. Each is a pure data computation in validated SAS (PROC FREQ/MEANS/SQL/REPORT, ODS) or R (dplyr + admiral-style derivations + gt/flextable + safetyGraphics for eDISH). A flag is a prompt to look — never an adjudication.

CheckWhat it computesThe honest boundary
AE / SAE counts & ratesRunning tallies by cohort, MedDRA SOC/PT, grade, relatedness; participant-incidence and event counts.Participant-incidence vs event counts — report both. Causality/expectedness come from the safety DB.
Candidate-DLT tally vs the ruleDLT-flagged events in the evaluation window per cohort vs the 3+3 / BOIN boundary.Only against an adjudicated DLT flag & a completed window. Never makes the escalation decision.
Hy’s-Law / eDISHPeak ALT or AST >3×ULN and total bilirubin >2×ULN (ALP<2× for hepatocellular); the eDISH quadrant plot.A screening flag matching the lab criteria — never a Hy’s-Law/DILI diagnosis.
QTcF prolongationAbsolute >450/480/500 ms tiers and ΔQTcF >30/>60 ms (ICH E14), by participant/visit/dose.Use the protocol correction (don’t mix QTcF/QTcB). Threshold crossing only.
Labs / vitals out-of-range & shiftsValues outside reference / PCS criteria; baseline-to-worst shift tables by CTCAE grade.Reference ranges & PCS criteria are the validated study-specific metadata. Out-of-range is statistical, not clinical.
PK exposure outliersCmax / AUC outliers vs cohort and vs prior cohorts; pre-specified exposure ceilings.Thresholds must be pre-specified in the protocol/SAP to stay deterministic.
Enrollment vs plan & screen-failActual vs planned by cohort & over time; screen-failure rate with reasons; cohort-fill.Pure operational counting; depends on disposition data being current.
Query aging & missing safety dataOpen queries by age bucket & site; missing/overdue critical safety fields in the DLT window.Flags presence/age of queries, not whether the data is correct.
Protocol deviationsCounts by category & severity, by site & over time, with a spike trend flag.Counts & trends as logged; does not classify severity (a quality/medical judgment).
Visit complianceMissed / overdue / out-of-window visits vs the schedule — critical for DLT-window safety visits & PK timepoints.Run-date dependent (uses “today”), so a daily job keeps “overdue” current.
Randomization / IXRS reconciliationEvery randomized participant in both systems; kit/dose matches cohort; drug accountability.Discrepancies escalate to a human (DM / unblinded pharmacist); never auto-resolved; blinding preserved.

The catalog above says what is checked. Here is the library that implements it, in full, plus the R twin. These are the actual shipped files, also available as downloads.

The complete SAS monitoring library: tm_macros.sas

SAS · 344 lines · the real shipped file, in full — nothing elided.

tm_macros.sas is the complete TRIALMON macro library: a deterministic, no-AI Base SAS 9.4 backbone for unattended early-phase trial monitoring, designed for covering a study while a biostatistician is on leave. It defines every %tm_* macro in one loop: scheduler to ingest to freshness gate to guard to safety and operational checks to status roll-up to a tier-1 digest, with a de-duplicated tier-2 RED alert and a closing heartbeat. The library is loaded once (via %include) at the top of a driver such as monitor_driver.sas, then its macros are called in order on each scheduled run. Every number and every check here is owned by validated SAS: these macros only DETECT, PACKAGE, and EMAIL pre-specified deterministic flags, and a human (the medical monitor, the covering biostatistician) owns every decision.

/*====================================================================================
  tm_macros.sas  -  TRIALMON: a deterministic SAS macro library for unattended
                    early-phase trial-monitoring automation (no AI).
  ------------------------------------------------------------------------------------
  Loop : scheduler -> ingest -> FRESHNESS GATE -> GUARD -> checks -> roll-up -> digest,
         + a tier-2 urgent ALERT on a de-duplicated RED finding, always closed by a
         HEARTBEAT. Built for "covering while on leave"; the standing monitoring backbone.
  Prefix  : %tm_<name>.  Tested concept: Base SAS 9.4. No third-party packages.
  Honest  : these macros DETECT, PACKAGE and EMAIL pre-specified deterministic flags.
            They DO NOT triage, interpret, or adjudicate. A flag is a prompt for a human;
            the medical monitor and the covering biostatistician decide.
  Validate: validate the threshold logic + scheduling wrapper as study programs; keep
            anything REPORTED independently double-programmed.
  v2 - corrected after an independent SAS/clinical review (see README, "Change log").
====================================================================================*/

%global TM_STUDY TM_RUNID TM_RUNDT TM_OUT TM_LOG TM_STATE TM_RC TM_STATUS TM_FRESH_OK TM_BACKUP;

/*==== %tm_init : initialise a run (paths, options, run id, persistent state lib) =====*/
%macro tm_init(study=, root=, smtp=, statelib=, backup=);
  %let TM_STUDY = &study;
  %let TM_RUNDT = %sysfunc(datetime(), e8601dt19.);
  %let TM_RUNID = &study._%sysfunc(datetime(), b8601dn15.);
  %let TM_OUT   = &root/out;
  %let TM_LOG   = &root/logs;
  %let TM_RC    = 0;            /* run return code: 0 ok, >0 problem (fail loud)        */
  %let TM_STATUS= GREEN;        /* GREEN < AMBER < RED < ERROR                          */
  %let TM_FRESH_OK = 1;
  %let TM_BACKUP = &backup;     /* watchdog / fail-loud recipient                      */
  options nodate nonumber noquotelenmax;
  %if %length(&smtp) %then %do; options emailsys=smtp emailhost="&smtp" emailport=25; %end;
  %if %length(&statelib) %then %do; libname tmstate "&statelib"; %let TM_STATE = tmstate; %end;
  %else %let TM_STATE = work;
  %put NOTE: [TRIALMON] init study=&TM_STUDY run=&TM_RUNID out=&TM_OUT;
%mend tm_init;

/*==== %tm_assert : FAIL LOUD. For an unattended job "no news" must never be good news.
       On a false condition: set TM_RC, force status to ERROR, email the backup, write a
       FAILED heartbeat, and (if abort=Y) stop. Wrap every fragile step in this.        */
%macro tm_assert(cond=, msg=, abort=N);
  %if not (&cond) %then %do;
    %let TM_RC = 1; %let TM_STATUS = ERROR;
    %put ERROR: [TRIALMON] ASSERT FAILED: &msg;
    data _null_; file "&TM_OUT/heartbeat.txt";
      put "&TM_STUDY FAILED " "%sysfunc(datetime(),datetime19.)" " &msg"; run;
    %if %length(&TM_BACKUP) %then %do;
      data _null_; file "&TM_OUT/_fail_body.txt"; put "TRIALMON job FAILED: &msg"; put "Run &TM_RUNID - the unattended monitor needs attention. Do not assume GREEN."; run;
      %tm_email(to=&TM_BACKUP, subject=TRIALMON FAILURE - &TM_STUDY - &msg, bodyfile=&TM_OUT/_fail_body.txt, importance=High);
    %end;
    %if &abort=Y %then %abort cancel;
  %end;
%mend tm_assert;

/*==== %tm_guard : before any safety threshold runs, assert the expected columns exist,
       are numeric, and pass a range-sanity check (catches a unit change / renamed var).*/
%macro tm_guard(ds=, numvars=, sanity=);
  %tm_assert(cond=%sysfunc(exist(&ds)), msg=missing dataset &ds, abort=Y);
  %let i=1;
  %do %while(%scan(&numvars,&i,%str( )) ne );
    %let v=%scan(&numvars,&i,%str( ));
    %let dsid=%sysfunc(open(&ds)); %let vn=%sysfunc(varnum(&dsid,&v)); %let vt=;
    %if &vn %then %let vt=%sysfunc(vartype(&dsid,&vn)); %let rc=%sysfunc(close(&dsid));
    %tm_assert(cond=(&vn>0), msg=&ds missing expected column &v);
    %tm_assert(cond=(%superq(vt)=N), msg=&ds column &v is not numeric (unit/type change?));
    %let i=%eval(&i+1);
  %end;
  %if %length(&sanity) %then %do;
    proc sql noprint; select count(*) into :_bad trimmed from &ds where not (&sanity); quit;
    %tm_assert(cond=(&_bad=0), msg=&ds failed range-sanity (&sanity) on &_bad rows - possible unit error);
  %end;
%mend tm_guard;

/*==== %tm_latest : newest dated file in a dir (unambiguous "latest export") ===========*/
%macro tm_latest(dir=, ext=sas7bdat, outvar=tm_latest_path);
  %global &outvar;
  filename _tmdir "&dir";
  data _tm_files;
    length fname $260 fpath $400; format mtime datetime19.;
    did = dopen("_tmdir");
    if did then do i = 1 to dnum(did);
      fname = dread(did, i);
      if lowcase(scan(fname,-1,'.')) = lowcase("&ext") then do;
        fpath = catx('/', "&dir", fname);
        rc = filename("_tmf", fpath); fid = fopen("_tmf");
        if fid then do; mtime = input(finfo(fid,"Last Modified"), anydtdtm40.); rc=fclose(fid); end; /* numeric dttm - sorts chronologically */
        rc = filename("_tmf"); output;
      end;
    end;
    rc = dclose(did);
  run;
  proc sql noprint; select fpath into :&outvar trimmed from _tm_files having mtime = max(mtime); quit;
  %put NOTE: [TRIALMON] latest &ext in &dir -> &&&outvar;
%mend tm_latest;

/*==== %tm_freshness : THE GATE (run FIRST). One coherent per-feed pass -> &out. A
       MISSING / dead feed is the worst case and must flag (never silently pass green). */
%macro tm_freshness(feeds=, tsvar=_loaddttm, maxage=26, out=_tm_fresh);
  data &out; length feed $41 status $7 sev $6; format newest age_h 8.; stop; run;
  %let i=1;
  %do %while(%scan(&feeds,&i,%str( )) ne );
    %let f=%scan(&feeds,&i,%str( ));
    %if %sysfunc(exist(&f)) %then %do;
      proc sql noprint; select max(&tsvar) into :_mx trimmed from &f; quit;
    %end; %else %let _mx=.;
    data _r; length feed $41 status $7 sev $6; format newest age_h 8.;
      feed="&f"; newest=&_mx;
      if missing(newest) then do; status="MISSING"; age_h=.; sev="AMBER"; end;     /* dead feed -> flag loud */
      else do; age_h=(datetime()-newest)/3600;
               if age_h > &maxage then do; status="STALE"; sev="AMBER"; end;
               else do; status="FRESH"; sev="GREEN"; end; end;
    run;
    proc append base=&out data=_r force; run;
    %let i=%eval(&i+1);
  %end;
  proc sql noprint; select sum(sev ne "GREEN")>0 into :_anystale trimmed from &out; quit;
  %if &_anystale %then %do; %let TM_FRESH_OK=0; %if &TM_STATUS=GREEN %then %let TM_STATUS=AMBER; %let TM_RC=1;
    %put WARNING: [TRIALMON] one or more feeds STALE/MISSING - GREEN on stale data is worse than RED; %end;
%mend tm_freshness;

/*==== %tm_status : roll a flagged dataset's worst sev into the run status (exist-guarded)*/
%macro tm_status(in=, sevvar=sev);
  %if %sysfunc(exist(&in))=0 %then %do; %put WARNING: [TRIALMON] &in missing - status skipped; %tm_assert(cond=0, msg=check output &in missing); %return; %end;
  proc sql noprint;
    select case when sum(&sevvar="RED")>0 then "RED"
                when sum(&sevvar="AMBER")>0 then "AMBER" else "GREEN" end
      into :_worst trimmed from &in;
  quit;
  %if &_worst=RED and &TM_STATUS ne ERROR %then %let TM_STATUS=RED;
  %else %if &_worst=AMBER and &TM_STATUS in (GREEN) %then %let TM_STATUS=AMBER;
  %put NOTE: [TRIALMON] %superq(in) worst=&_worst run-status=&TM_STATUS;
%mend tm_status;

/*====================================================================================
  SAFETY CHECKS  ->  each returns a flagged dataset with a SEV column. Deterministic.
====================================================================================*/

/* ---- Hy's-Law / eDISH screening flag (CORRECTED) -----------------------------------
   RED if (ALT or AST > altmult x ULN) AND (TBili > bilmult x ULN).  STRICT '>'.
   NEVER downgraded by ALP: a high ALP does NOT exclude Hy's Law. Report the R-ratio
   (ALT/ULN over ALP/ULN) for CONTEXT only - hepatocellular when R>=5.  in: USUBJID +
   peak alt_x ast_x tbl_x alp_x (x-ULN).  Screening flag, NOT a diagnosis.            */
%macro tm_chk_hyslaw(in=, out=_tm_hyslaw, altmult=3, astmult=3, bilmult=2);
  data &out;
    set &in;
    length flag $60 sev $6;
    hy = ((alt_x > &altmult) or (ast_x > &astmult)) and (tbl_x > &bilmult);   /* strict > */
    if hy then do;
      r_ratio = divide(alt_x, alp_x);                      /* context only - never downgrades */
      length pattern $14; pattern = ifc(r_ratio>=5,"hepatocellular",ifc(r_ratio<=2,"cholestatic","mixed"));
      flag = catx(' ', "Hy's-Law pattern (", strip(pattern), "R=", put(r_ratio,4.1), ")");
      sev = "RED"; output;
    end;
  run;
  %put NOTE: [TRIALMON] Hy's-Law screen (SCREENING flag - ALP never downgrades a RED case);
%mend tm_chk_hyslaw;

/* ---- QTcF prolongation flags (ICH E14): 450 watch / 480 AMBER / 500 RED ------------*/
%macro tm_chk_qtcf(in=, out=_tm_qtcf, abs_red=500, dqt_red=60, abs_amb=480, dqt_amb=30, abs_low=450);
  data &out;
    set &in;
    length flag $40 sev $6;
    if QTCF > &abs_red or DQTCF > &dqt_red then sev="RED";
    else if QTCF > &abs_amb or DQTCF > &dqt_amb then sev="AMBER";
    else if QTCF > &abs_low then sev="GREEN";   /* tracked watch level */
    else delete;
    flag = cats("QTcF ",put(QTCF,4.)," / dQTcF ",put(DQTCF,4.));
    if sev ne "GREEN" then output;              /* alert AMBER/RED; GREEN-watch tracked but not alerted */
  run;
%mend tm_chk_qtcf;

/* ---- AE / SAE running tally by cohort. AETOXGR is CHARACTER in CDISC -> input() -----
   SAE -> AMBER by default (scope RED to SUSAR/related via AEREL if your team prefers). */
%macro tm_chk_ae(in=, out=_tm_ae);
  proc sql;
    create table &out as
    select COHORT,
           count(distinct case when AESER='Y' then USUBJID end)                  as n_sae_subj,
           sum(AESER='Y')                                                         as n_sae_evt,
           count(distinct case when input(AETOXGR,best.)>=3 then USUBJID end)     as n_g3_subj,
           count(distinct USUBJID)                                               as n_ae_subj,
           case when sum(AESER='Y')>0 or count(distinct case when input(AETOXGR,best.)>=3 then USUBJID end)>0
                then 'AMBER' else 'GREEN' end as sev length=6,
           catx(' ','cohort',COHORT,'-',put(calculated n_sae_evt,3.),'SAE,',put(calculated n_g3_subj,3.),'Gr>=3') as flag length=40
    from &in where TRTEMFL='Y' group by COHORT;
  quit;
%mend tm_chk_ae;

/* ---- Candidate-DLT tally vs 3+3. Group by the DOSE-LEVEL key (default COHORT - assert
   COHORT==dose level for this study, else pass dosevar=DOSELVL). Against an ADJUDICATED
   DLTFL and a completed window (DLT_EVAL='Y'). Does NOT make the escalation decision.  */
%macro tm_chk_dlt(in=, out=_tm_dlt, dosevar=COHORT);
  proc sql;
    create table &out as
    select &dosevar,
           sum(DLT_EVAL='Y')               as n_eval,
           sum(DLTFL='Y' and DLT_EVAL='Y') as n_dlt,
           case when calculated n_dlt >= 2 then 'RED'
                when calculated n_dlt = 1  then 'AMBER' else 'GREEN' end as sev length=6,
           catx(' ','dose',&dosevar,'DLT',put(calculated n_dlt,2.),'/',put(calculated n_eval,2.)) as flag length=40
    from &in group by &dosevar;
  quit;
  %put NOTE: [TRIALMON] DLT tally vs 3+3 - flags the count; does NOT make the escalation call;
%mend tm_chk_dlt;

/* ---- Labs / vitals out-of-range & PCS ----------------------------------------------*/
%macro tm_chk_labs(in=, out=_tm_labs);
  data &out; set &in; length flag $40 sev $6;
    if PCSFL='Y' then do; flag=cats(PARAM," PCS"); sev="AMBER"; output; end;
  run;
%mend tm_chk_labs;

/*==== OPERATIONAL CHECKS =============================================================*/
%macro tm_chk_enroll(in=, out=_tm_enroll, kri=0.80);
  data &out; set &in; length flag $60 sev $6;
    pct = divide(N_ENROLLED, N_PLANNED_TO_DATE);
    if pct < &kri then do; sev="AMBER"; flag=cats("Enrollment ",put(pct,percent7.1)," < KRI ",put(&kri,percent7.0)); end;
    else do; sev="GREEN"; flag=cats("Enrollment ",put(pct,percent7.1)," (on track)"); end;
    output;
  run;
%mend tm_chk_enroll;
%macro tm_chk_queries(in=, out=_tm_qry, age_amb=30);
  proc sql; create table &out as
    select sum(OPEN_FL='Y') as n_open, sum(OPEN_FL='Y' and QUERY_AGE_DAYS>&age_amb) as n_aged,
           case when sum(OPEN_FL='Y' and QUERY_AGE_DAYS>&age_amb)>0 then 'AMBER' else 'GREEN' end as sev length=6,
           catx(' ',put(calculated n_open,4.),'open,',put(calculated n_aged,4.),'aged >&age_amb.d') as flag length=40
    from &in; quit;
%mend tm_chk_queries;
%macro tm_chk_visits(in=, out=_tm_vis);
  data &out; set &in; length flag $40 sev $6;
    if missing(ACTDT) and PLANDT < today() then do; flag=cats("Overdue ",strip(VISIT)); sev="AMBER"; output; end;
    else if not missing(ACTDT) and abs(ACTDT-PLANDT) > WINDOW_DAYS then do; flag=cats("Out-of-window ",strip(VISIT)); sev="AMBER"; output; end;
  run;
%mend tm_chk_visits;
%macro tm_chk_ixrs(clin=, ixrs=, out=_tm_ixrs);
  proc sql; create table &out as
    select coalesce(a.USUBJID,b.USUBJID) as USUBJID, a.TRT01P, b.IXRS_TRT,
           case when a.USUBJID is null then 'In IXRS, not in clinical DB'
                when b.USUBJID is null then 'Randomized, missing in IXRS'
                when a.TRT01P ne b.IXRS_TRT then 'Treatment mismatch' else '' end as flag length=40,
           'AMBER' as sev length=6
    from &clin a full join &ixrs b on a.USUBJID=b.USUBJID where calculated flag ne ''; quit;
  %put NOTE: [TRIALMON] IXRS reconciliation - discrepancies escalate to DM/unblinded pharmacist, never auto-resolved;
%mend tm_chk_ixrs;

/*==== %tm_evidence : per-RED-participant one-page PDF (eDISH + lab trajectory) ============
   in: the RED hyslaw dataset (USUBJID, alt_x..); lab: full lab history for the plot.   */
%macro tm_evidence(in=, lab=, out=);
  %let out=%sysfunc(coalescec(&out,&TM_OUT/evidence_&TM_RUNID..pdf));
  ods pdf file="&out" style=journal; options nobyline;
  proc sql noprint; select distinct USUBJID into :_subs separated by ' ' from &in; quit;
  %if %length(&_subs)=0 %then %do; ods pdf close; %return; %end;
  /* eDISH scatter with 3xULN / 2xULN reference lines, RED participant highlighted */
  proc sgplot data=&lab;
    title "Evidence packet - eDISH (peak ALT vs peak total bilirubin, x-ULN) - Run &TM_RUNID";
    scatter x=alt_x y=tbl_x / group=red_fl markerattrs=(symbol=circlefilled);
    refline 3 / axis=x lineattrs=(pattern=shortdash); refline 2 / axis=y lineattrs=(pattern=shortdash);
    xaxis label="peak ALT (x ULN)"; yaxis label="peak total bilirubin (x ULN)";
  run;
  /* per-participant lab trajectory */
  %let j=1;
  %do %while(%scan(&_subs,&j,%str( )) ne );
    %let su=%scan(&_subs,&j,%str( ));
    proc sgplot data=&lab(where=(USUBJID="&su"));
      title "Participant &su - lab trajectory (x ULN by visit)";
      series x=visitnum y=alt_x / markers; series x=visitnum y=ast_x / markers;
      series x=visitnum y=tbl_x / markers; series x=visitnum y=alp_x / markers;
    run; %let j=%eval(&j+1);
  %end;
  title; ods pdf close;
  %put NOTE: [TRIALMON] evidence packet -> &out;
%mend tm_evidence;

/*==== DE-DUPLICATION (key MUST include participant so distinct participants are not collapsed)*/
%macro tm_dedup(in=, key=, out=_tm_new);
  %if %sysfunc(exist(&TM_STATE..tm_seen))=0 %then %do;
    data &TM_STATE..tm_seen; length sigkey $200; format first_dttm datetime19.; first_dttm=.; stop; run; %end;
  proc sql;
    create table &out as select a.* from &in a where a.&key not in (select sigkey from &TM_STATE..tm_seen);
    insert into &TM_STATE..tm_seen (sigkey, first_dttm)
      select distinct &key, datetime() from &in where &key not in (select sigkey from &TM_STATE..tm_seen);
  quit;
%mend tm_dedup;

/*==== NOTIFICATION ===================================================================*/
%macro tm_email(to=, cc=, subject=, bodyfile=, attach=, importance=Normal);
  filename _tmmail email to=(&to) %if %length(&cc) %then cc=(&cc); subject="&subject"
     %if %length(&attach) %then %if %sysfunc(fileexist(&attach)) %then attach=("&attach"); importance="&importance";
  data _null_; file _tmmail;
    %if %length(&bodyfile) %then %do; infile "&bodyfile" length=l; input line $varying2000. l; put line; %end;
    %else %do; put "&TM_STUDY &subject"; %end;
  run;
  filename _tmmail clear;
  %put NOTE: [TRIALMON] emailed "&subject" to &to;
%mend tm_email;

/* tier-1 digest: ODS report + RAG status, always with a heartbeat line. Body created here. */
%macro tm_digest(title=, sections=, to=, dest=pdf);
  %let _f=&TM_OUT/digest_&TM_RUNID..&dest;
  %let _imp=Normal; %if &TM_STATUS=RED or &TM_STATUS=ERROR %then %let _imp=High;
  ods &dest file="&_f" style=journal; ods escapechar='^';
  title "^S={font_size=14pt}&title - %sysfunc(today(),date9.) - [&TM_STATUS]";
  %let i=1; %do %while(%scan(&sections,&i,%str( )) ne ); %let s=%scan(&sections,&i,%str( ));
    %if %sysfunc(exist(&s)) %then %do; proc print data=&s noobs label; run; %end; %let i=%eval(&i+1); %end;
  data _hb; length txt $200;
    txt=cats("HEARTBEAT ran ",put(datetime(),datetime19.)," SYSCC=&TM_RC data-fresh=",ifc(symgetn('TM_FRESH_OK'),'OK','STALE/MISSING')); run;
  proc print data=_hb noobs; run; title; ods &dest close;
  data _null_; file "&TM_OUT/_digest_body.txt";
    put "&TM_STUDY daily digest [&TM_STATUS] - %sysfunc(today(),date9.) - see attached PDF."; run;
  %tm_email(to=&to, subject=&TM_STUDY digest [&TM_STATUS] %sysfunc(today(),date9.),
            bodyfile=&TM_OUT/_digest_body.txt, attach=&_f, importance=&_imp);
%mend tm_digest;

/* tier-2 urgent ALERT (de-identified body; severity routing; contact-the-MM; evidence) */
%macro tm_alert(in=, to=, backup=, alias=, cc=, mm_name=, mm_phone=, evidence=);
  %if %sysfunc(exist(&in))=0 %then %return;
  proc sql noprint; select count(*) into :_n trimmed from &in where sev="RED"; quit;
  %if &_n=0 %then %do; %put NOTE: [TRIALMON] no RED findings to alert; %return; %end;
  data _null_; file "&TM_OUT/_alert_body.txt";
    put "URGENT safety alert - &TM_STUDY - auto-sent " "%sysfunc(datetime(),datetime19.)";
    put "A pre-specified deterministic threshold was met. SCREENING flag, NOT a diagnosis. The medical monitor adjudicates.";
    put " "; put ">> CONTACT THE MEDICAL MONITOR NOW: &mm_name &mm_phone";
    put ">> Acknowledge within 30 minutes (reply to confirm receipt)."; put ">> Evidence packet attached."; put " ";
  run;
  data _null_; set &in(where=(sev="RED")); file "&TM_OUT/_alert_body.txt" mod; put "  - " flag; run;
  %tm_email(to=&to, cc=&backup &alias &cc, subject=RED ALERT - &TM_STUDY - safety threshold met - ACTION REQUIRED,
            bodyfile=&TM_OUT/_alert_body.txt, attach=&evidence, importance=High);
  %if &TM_STATUS ne ERROR %then %let TM_STATUS=RED;
  %put WARNING: [TRIALMON] RED alert dispatched (primary+backup+alias);
%mend tm_alert;

/*==== %tm_heartbeat : the dead-man's switch. An INDEPENDENT watcher (separate host/cron,
       e.g. tm_watchdog.sh) escalates if this ping stops. "No news" is never "good news".*/
%macro tm_heartbeat(records=, watcher_to=);
  %if %sysfunc(exist(&TM_STATE..tm_heartbeat))=0 %then %do;
    data &TM_STATE..tm_heartbeat; length runid $40 status $7; format run_dttm datetime19.; run_dttm=.; stop; run; %end;
  proc sql; insert into &TM_STATE..tm_heartbeat (runid, run_dttm, status)
              values ("&TM_RUNID", %sysfunc(datetime()), "&TM_STATUS"); quit;
  data _null_; file "&TM_OUT/heartbeat.txt";
    put "&TM_STUDY &TM_STATUS " "%sysfunc(datetime(),datetime19.)" " SYSCC=&TM_RC N=&records FRESH=&TM_FRESH_OK"; run;
  %put NOTE: [TRIALMON] heartbeat written - external watcher (&watcher_to) alerts if it stops;
%mend tm_heartbeat;

/*==== End of tm_macros.sas. See monitor_driver.sas, tm_watchdog.sh, tm_config.sas, and
      the README for the catalog, parameters, the config pattern, and validation notes. */

Walkthrough, part by part

Header banner and design contract. The opening comment block states the whole contract you are building against: a deterministic, no-AI SAS library for unattended early-phase monitoring, whose loop runs scheduler to ingest to freshness gate to guard to checks to roll-up to digest, plus a tier-2 urgent alert on a de-duplicated RED finding and a closing heartbeat. It is explicit that these macros DETECT, PACKAGE, and EMAIL flags but do NOT triage, interpret, or adjudicate: a flag is a prompt for a human, and the medical monitor and covering biostatistician decide. It tells you to validate the threshold logic and scheduling wrapper as study programs and to keep anything REPORTED independently double-programmed. Reading this first tells you where the human gate sits and what you are on the hook to validate before this runs on a live study.

Global macro variables. The single %global statement declares the run-scoped state that every macro reads and writes: TM_STUDY, TM_RUNID, TM_RUNDT, TM_OUT, TM_LOG, TM_STATE, TM_RC, TM_STATUS, TM_FRESH_OK, and TM_BACKUP. Declaring them global once means the status, return code, and freshness flags set by one macro (for example the freshness gate) are visible to the digest, alert, and heartbeat later in the same run. When you build, treat these as the shared bus: the checks push severity into TM_STATUS and problems into TM_RC, and the closing macros read them back.

%tm_init: initialise a run. %tm_init stamps the run: it sets TM_STUDY, a human-readable TM_RUNDT and a sortable TM_RUNID from the study name and the current datetime, derives the out and logs directories under root, and seeds TM_RC=0, TM_STATUS=GREEN, and TM_FRESH_OK=1. It sets safe options (nodate nonumber noquotelenmax), and only if smtp is supplied does it configure SMTP email; only if statelib is supplied does it assign a persistent tmstate library and point TM_STATE at it, otherwise TM_STATE falls back to work. It also captures TM_BACKUP, the fail-loud recipient used later by the assertion macro. Call this first in your driver, and pass a real statelib so de-duplication and heartbeat history survive across runs.

%tm_assert: fail loud. %tm_assert is the fail-loud primitive: on a false cond it sets TM_RC=1, forces TM_STATUS to ERROR, writes a FAILED line into heartbeat.txt, and, if TM_BACKUP is set, emails the backup a high-importance failure notice telling the recipient not to assume GREEN. If abort=Y it stops the step with %abort cancel. This exists because for an unattended job silence must never read as success: a broken step has to surface loudly rather than pass green. Wrap every fragile step (dataset existence, column types, range sanity) in this so a covering biostatistician is paged rather than misled.

%tm_guard: pre-check data contract. %tm_guard enforces the data contract before any safety threshold runs. It asserts (aborting) that ds exists, then loops over the space-delimited numvars using open/varnum/vartype/close to assert each expected column is present and numeric, which catches a renamed variable or a unit/type change. If a sanity expression is supplied it counts rows failing where not (&sanity) and asserts the count is zero, flagging a possible unit error. This matters because a silent unit change (mg to ng, or a re-derived x-ULN) would otherwise let a threshold fire wrongly or not at all: guard the inputs so the downstream numbers stay trustworthy.

%tm_latest: newest dated file in a directory. %tm_latest resolves the unambiguous latest export from a directory. It opens dir with dopen, reads each entry, keeps files whose extension matches ext (default sas7bdat), and for each opens the file to read its Last Modified attribute via finfo, parsing it into a numeric SAS datetime that sorts chronologically. A final proc sql selects the fpath whose mtime equals the maximum into the requested output macro variable (default tm_latest_path). Use this when the feed drops timestamped files and you must ingest the newest one deterministically rather than guessing by filename.

%tm_freshness: the gate (run first). %tm_freshness is THE gate and is meant to run first: for each feed in feeds it reads max(&tsvar) (default _loaddttm) as the newest timestamp, computes age in hours, and classifies each feed as MISSING (dead feed, AMBER), STALE if older than maxage hours (default 26, AMBER), or FRESH (GREEN), appending one row per feed to out. If any feed is not GREEN it clears TM_FRESH_OK to 0, bumps TM_STATUS from GREEN to AMBER, sets TM_RC=1, and warns that GREEN on stale data is worse than RED. This is the core honesty mechanism: a dead or stale feed must flag loudly rather than let the run report a false all-clear, so build it as the first check in the sequence.

%tm_status: roll worst severity into run status. %tm_status rolls one flagged dataset's worst severity into the overall run status. If in does not exist it warns and calls %tm_assert with a false condition (so a missing check output itself fails loud), then returns. Otherwise it computes the dataset's worst sevvar (RED beats AMBER beats GREEN) and promotes TM_STATUS to RED (unless already ERROR) or to AMBER (only when currently GREEN), never downgrading a worse status. Call it once per check output so the final TM_STATUS reflects the most severe finding across all checks, which is what the digest importance and alert routing key off.

%tm_chk_hyslaw: Hy's-Law / eDISH screening flag. %tm_chk_hyslaw flags RED when (alt_x or ast_x exceeds its multiple, defaults 3) AND tbl_x exceeds bilmult (default 2), using strict greater-than, on peak x-ULN inputs keyed by USUBJID. The comment is emphatic that a high ALP does NOT exclude Hy's Law: the R-ratio (alt_x over alp_x) is computed and labelled hepatocellular, cholestatic, or mixed for CONTEXT only and never downgrades a RED case. It writes a descriptive flag string with the pattern and R value and outputs only the participants who meet the rule. This is a screening flag, not a diagnosis: it hands the medical monitor a pre-specified deterministic case to adjudicate, and the corrected logic (ALP never downgrades) is the load-bearing fix from the independent review.

%tm_chk_qtcf: QTcF prolongation flags. %tm_chk_qtcf applies ICH E14 QTcF thresholds: RED if QTCF exceeds abs_red (500) or DQTCF exceeds dqt_red (60), AMBER if above abs_amb (480) or dqt_amb (30), and GREEN as a tracked watch level if above abs_low (450); anything below is deleted. It builds a flag showing the QTcF and dQTcF values and outputs only AMBER and RED rows, so the GREEN watch level is retained in logic but not alerted. This gives you the standard 450 watch / 480 AMBER / 500 RED tiering with change-from-baseline as a parallel trigger, which is what a cardiac safety reviewer expects to see surfaced.

%tm_chk_ae: AE / SAE running tally by cohort. %tm_chk_ae summarises treatment-emergent AEs (where TRTEMFL='Y') grouped by COHORT: distinct participants and event counts for serious AEs (AESER='Y'), distinct participants with a Grade 3 or higher event (via input(AETOXGR,best.) since AETOXGR is character in CDISC), and total distinct AE participants. Severity is AMBER when there is any SAE or any Grade 3-plus participant, else GREEN, with a compact flag string per cohort. The comment notes SAE defaults to AMBER and that a team may instead scope RED to SUSAR or related events via AEREL. Use this as the cohort-level running tally; the input() conversion is the detail to preserve so grade comparisons are numeric.

%tm_chk_dlt: candidate-DLT tally vs 3+3. %tm_chk_dlt counts adjudicated DLTs against a 3+3 frame, grouped by the dose-level key (dosevar, default COHORT, with the comment asking you to assert COHORT equals dose level or else pass DOSELVL). It counts evaluable participants (DLT_EVAL='Y') and DLTs (DLTFL='Y' and DLT_EVAL='Y'), then sets RED at two or more DLTs, AMBER at exactly one, else GREEN. The flag concatenates the literal word dose, the dose-level value, the token DLT, the DLT count, a slash, and the evaluable count. It runs against an already-adjudicated DLTFL and a completed evaluation window, and the note states plainly it flags the count and does NOT make the escalation call: the escalation decision stays with the dose-escalation committee while this macro only surfaces the tally.

%tm_chk_labs: labs / vitals out-of-range and PCS. %tm_chk_labs is a compact potentially-clinically-significant screen: for rows where PCSFL='Y' it emits an AMBER flag naming the PARAM plus the token PCS and outputs only those rows. It relies on an upstream-derived PCS flag rather than re-deriving reference ranges here, so the criterion for clinical significance lives in the validated lab-flagging program that set PCSFL. Keep that derivation double-programmed if any of it feeds a reported number, since this macro trusts the flag it is handed.

%tm_chk_enroll: enrollment vs KRI. %tm_chk_enroll is the first operational check: it computes pct = divide(N_ENROLLED, N_PLANNED_TO_DATE) and flags AMBER when the fraction is below the key risk indicator kri (default 0.80), else GREEN with an on-track note, and always outputs a row per input record. It uses divide() so a zero planned-to-date does not throw. This surfaces under-enrollment against plan as an operational amber for the covering biostatistician and the study team, not a safety signal.

%tm_chk_queries: open and aged data queries. %tm_chk_queries tallies data-management queries in one proc sql: n_open counts open queries (OPEN_FL='Y') and n_aged counts open queries older than age_amb days (default 30), with severity AMBER when any aged query exists and a flag summarising both counts. It is an operational cleanliness signal: aged open queries are the amber, since they indicate data that has been sitting unresolved. Point it at your query-status extract and it gives the team a running open-and-aged number each run.

%tm_chk_visits: overdue and out-of-window visits. %tm_chk_visits flags scheduling problems per participant-visit: it emits an AMBER Overdue flag when ACTDT is missing and PLANDT is before today(), and an AMBER Out-of-window flag when an actual date exists but its absolute distance from the planned date exceeds WINDOW_DAYS. Only flagged rows are output. This catches both missed visits and visits performed outside their protocol window, both of which are operational amber items the site and CRA need to see.

%tm_chk_ixrs: clinical-to-IXRS reconciliation. %tm_chk_ixrs full-joins the clinical dataset (clin) to the IXRS/RTSM dataset (ixrs) on USUBJID and flags AMBER discrepancies: a participant present in IXRS but not clinical, randomized but missing in IXRS, or a treatment mismatch between TRT01P and IXRS_TRT, keeping only rows with a non-empty flag. The note is load-bearing: these discrepancies escalate to data management or the unblinded pharmacist and are NEVER auto-resolved. The macro only detects the mismatch; a human reconciles it, which keeps the blind and the treatment assignment under human control.

%tm_evidence: per-RED-participant one-page PDF. %tm_evidence builds the evidence packet attached to a RED alert. It defaults the output path under TM_OUT, opens an ODS PDF, pulls the distinct RED USUBJIDs from in (returning early and closing the PDF if that list is empty), draws an eDISH scatter of peak ALT versus peak total bilirubin (x-ULN) with 3x and 2x reference lines and the RED participants highlighted via red_fl, then loops each RED participant to plot their per-visit ALT, AST, TBili, and ALP trajectory. This packages the underlying data behind the flag so the medical monitor sees the trajectory, not just the alert line. It reads from the full lab history you pass as lab, so make sure that source is the validated lab dataset.

%tm_dedup: signature de-duplication across runs. %tm_dedup stops the same finding from re-alerting every run. It creates the persistent tm_seen table (with a sigkey column) in TM_STATE on first use, selects into out only the input rows whose key is not already stored in sigkey, then inserts the new distinct key values with the current datetime as first_dttm. The header comment is a hard rule: the key MUST include the participant so distinct participants with the same numeric pattern are not collapsed into one and silently suppressed. When you build the alert path, construct the dedup key from USUBJID plus the check signature so a genuine new participant always breaks through.

%tm_email: notification primitive. %tm_email is the shared send primitive used by the assertion, digest, and alert macros. It assigns a _tmmail email fileref with to, optional cc, subject, optional attachment (attached only if the file actually exists), and importance, then writes the body either from a supplied bodyfile (read line by line) or a default one-line body, and clears the fileref. It relies on the SMTP options set in %tm_init. This is the single choke point for outbound mail, so any change to routing or formatting is made here once.

%tm_digest: tier-1 daily digest with heartbeat. %tm_digest produces the routine tier-1 report. It builds an ODS file (default PDF) under TM_OUT, titles it with the study, date, and current TM_STATUS, and prints each existing dataset named in sections. It then always appends a HEARTBEAT line reporting the run time, SYSCC=&TM_RC, and whether data was fresh (reading TM_FRESH_OK via symgetn), and emails the digest with importance escalated to High when status is RED or ERROR. The always-present heartbeat and the freshness line mean a reader can never mistake a stale or failed run for a clean one; the RAG status in the title carries the roll-up from every check.

%tm_alert: tier-2 urgent RED alert. %tm_alert is the tier-2 urgent path and returns immediately if in does not exist or contains no RED rows. When RED findings exist it writes a de-identified body stating a pre-specified deterministic threshold was met, that this is a SCREENING flag and NOT a diagnosis that the medical monitor adjudicates, and instructing the reader to contact the named medical monitor (mm_name, mm_phone) now and acknowledge within 30 minutes, then appends each RED flag line. It emails to as primary and puts backup, alias, and cc on the CC line at High importance with the evidence packet attached, forces TM_STATUS to RED unless already ERROR, and warns that a RED alert was dispatched. The body carries only flags and contact instructions, keeping the alert de-identified while pointing a human straight to the decision-maker.

%tm_heartbeat: the dead-man's switch. %tm_heartbeat closes every run and is the dead-man's switch. It creates the persistent tm_heartbeat table in TM_STATE if absent, inserts a row with TM_RUNID, the current datetime, and TM_STATUS, and overwrites heartbeat.txt with a one-line status carrying the study, status, timestamp, SYSCC=&TM_RC, the record count records, and FRESH=&TM_FRESH_OK. The comment names an INDEPENDENT watcher on a separate host or cron (for example tm_watchdog.sh, addressed by watcher_to) that escalates if the ping stops. This is why the whole design holds: if the job itself dies silently, the external watcher sees the missing heartbeat and raises the alarm, so no news is never treated as good news.

End-of-file pointer. The closing comment points you to the rest of the operating set: monitor_driver.sas (the driver that includes this library and calls the macros in order), tm_watchdog.sh (the independent external watcher), tm_config.sas (the config pattern), and the README (the macro catalog, parameters, and validation notes). When you build and run, this is your map: this file is only the library, and those companion files supply the sequencing, watchdog, per-study parameters, and the validation record you need to stand behind on a live study.

The same library in R: tm_companion.R

R · 139 lines · the real shipped file, in full — nothing elided.

tm_companion.R is the R companion to tm_macros.sas: a deterministic TRIALMON monitoring library that runs the same "ingest to freshness to guard to checks to report to notify" loop with no AI anywhere in it. It is meant to run unattended on a schedule (cronR or taskscheduleR) against ADaM XPT extracts, with packages pinned by renv for byte-reproducibility. Every reported number comes from these validated R functions and every check is deterministic; the functions detect and email pre-specified flags, they do not triage or judge, and a flag is a prompt for a human where the medical monitor decides. The header comment records that this is v2, corrected after an independent review to enforce strict Hy's-Law logic, an NA-safe freshness gate, %.0f formatting (not %d, since QTcF and dQTcF are doubles), lowercase columns, deferred de-dup commit, and fail-loud behavior.

# =====================================================================================
# tm_companion.R  -  TRIALMON R companion to tm_macros.sas (no AI).
# Same deterministic "ingest -> freshness -> guard -> checks -> report -> notify" loop.
# Schedule with cronR / taskscheduleR; pin packages with renv for byte-reproducibility.
# v2 - corrected after an independent review (strict Hy's-Law, NA-safe gate, %.0f,
#      lowercase columns, deferred de-dup commit, fail-loud assert/guard).
#
# Honest: these functions DETECT and EMAIL pre-specified deterministic flags. They do
# NOT triage or judge. A flag is a prompt for a human; the medical monitor decides.
# =====================================================================================
suppressPackageStartupMessages({
  library(dplyr); library(haven); library(gt); library(blastula)
})

# ---- FAIL LOUD: an unattended job must never let "no news" read as "good news" -------
tm_assert <- function(cond, msg, backup = NULL) {
  if (!isTRUE(cond)) {
    writeLines(sprintf("CP101 FAILED %s %s", format(Sys.time(), "%Y-%m-%dT%H:%M:%S"), msg), "out/heartbeat.txt")
    if (!is.null(backup)) try(tm_email(backup, paste("TRIALMON FAILURE -", msg),
                                       "The unattended monitor needs attention. Do not assume GREEN.", urgent = TRUE), silent = TRUE)
    stop("[TRIALMON] ASSERT FAILED: ", msg, call. = FALSE)
  }
}

# ---- read an extract and NORMALISE column names to lowercase (stable contract) -------
# haven::read_xpt preserves UPPERCASE CDISC names; lowercasing once means every check
# can reference alt_x / qtcf / usubjid regardless of the source casing.
tm_read <- function(path) {
  tm_assert(file.exists(path), paste("missing extract", path))
  read_xpt(path) |> rename_with(tolower)
}

# ---- GUARD: required columns exist, are numeric, and pass a range-sanity check -------
tm_guard <- function(df, numvars, sanity = NULL) {
  miss <- setdiff(numvars, names(df))
  tm_assert(length(miss) == 0, paste("missing columns:", paste(miss, collapse = ", ")))
  for (v in numvars) tm_assert(is.numeric(df[[v]]), paste(v, "is not numeric (unit/type change?)"))
  if (!is.null(sanity)) tm_assert(all(sanity(df), na.rm = TRUE), "failed range-sanity (possible unit error)")
  invisible(TRUE)
}

# ---- DATA-FRESHNESS GATE (run FIRST). NA-safe: a MISSING feed file is the worst case -
tm_freshness <- function(feeds, max_age_h = 26) {
  now <- Sys.time()
  out <- tibble::tibble(feed = names(feeds), path = unlist(feeds)) |>
    mutate(age_h = as.numeric(difftime(now, file.mtime(path), units = "hours")),
           stale = is.na(age_h) | age_h > max_age_h,                       # NA (absent file) -> stale
           sev   = ifelse(stale, "AMBER", "GREEN"))
  if (any(out$stale, na.rm = TRUE))
    message("WARNING [TRIALMON] STALE/MISSING FEED: ", paste(out$feed[out$stale], collapse = ", "),
            " -- GREEN on stale data is worse than RED")
  out
}

# ---- Hy's-Law / eDISH screening (CORRECTED): RED on ALT/AST>3 AND TBili>2, STRICT '>',
#      NEVER downgraded by ALP. R-ratio (ALT/ULN over ALP/ULN) is context only. --------
tm_chk_hyslaw <- function(df, altmult = 3, astmult = 3, bilmult = 2) {
  df |>
    mutate(hy = (alt_x > altmult | ast_x > astmult) & tbl_x > bilmult,
           r_ratio = alt_x / alp_x,
           pattern = case_when(r_ratio >= 5 ~ "hepatocellular", r_ratio <= 2 ~ "cholestatic", TRUE ~ "mixed")) |>
    filter(hy) |>
    transmute(usubjid, flag = sprintf("Hy's-Law pattern (%s, R=%.1f)", pattern, r_ratio), sev = "RED")
}

# ---- QTcF (ICH E14). Use %.0f, not %d (QTcF/dQTcF are doubles) -----------------------
tm_chk_qtcf <- function(df, abs_red = 500, dqt_red = 60, abs_amb = 480, dqt_amb = 30) {
  df |>
    mutate(sev = case_when(qtcf > abs_red | dqtcf > dqt_red ~ "RED",
                           qtcf > abs_amb | dqtcf > dqt_amb ~ "AMBER", TRUE ~ "GREEN")) |>
    filter(sev != "GREEN") |>
    transmute(usubjid, flag = sprintf("QTcF %.0f / dQTcF %.0f", qtcf, dqtcf), sev)
}

tm_chk_enroll <- function(n_enrolled, n_planned, kri = 0.80) {
  pct <- n_enrolled / n_planned
  tibble::tibble(flag = sprintf("Enrollment %.0f%% of plan (KRI %.0f%%)", 100*pct, 100*kri),
                 sev  = ifelse(pct < kri, "AMBER", "GREEN"))
}

tm_status <- function(...) {
  sevs <- unlist(lapply(list(...), function(d) d$sev))
  if (any(sevs == "RED")) "RED" else if (any(sevs == "AMBER")) "AMBER" else "GREEN"
}

# ---- de-dup: return fresh findings; COMMIT the seen-store only AFTER successful send -
tm_dedup <- function(new, key, store = "state/tm_seen.rds") {
  seen <- if (file.exists(store)) readRDS(store) else character(0)
  list(fresh = new[!new[[key]] %in% seen, , drop = FALSE], keys = unique(new[[key]]), store = store, seen = seen)
}
tm_commit_seen <- function(dd) saveRDS(union(dd$seen, dd$keys), dd$store)   # call ONLY after delivery succeeds

tm_email <- function(to, subject, body_md, attach = NULL, urgent = FALSE, smtp = creds_file("smtp_creds")) {
  em <- compose_email(body = md(body_md))
  if (!is.null(attach) && file.exists(attach)) em <- add_attachment(em, file = attach)
  smtp_send(em, to = to, from = "biostat-monitor@example.com",
            subject = paste0(if (urgent) "[URGENT] " else "", subject), credentials = smtp)
  message("NOTE [TRIALMON] emailed: ", subject)
}

# ---- per-RED-participant evidence packet: eDISH scatter + lab trajectory -> one PDF ------
tm_evidence <- function(red, lab, out = "out/evidence.pdf") {
  if (!nrow(red)) return(invisible(NULL))
  grDevices::pdf(out)
  with(lab, plot(alt_x, tbl_x, xlab = "peak ALT (x ULN)", ylab = "peak total bilirubin (x ULN)",
                 main = "eDISH evidence packet", pch = ifelse(lab$usubjid %in% red$usubjid, 19, 1)))
  abline(v = 3, h = 2, lty = 2)
  for (su in unique(red$usubjid)) {
    d <- lab[lab$usubjid == su, ]
    matplot(d$visitnum, d[, c("alt_x","ast_x","tbl_x","alp_x")], type = "b", pch = 1,
            xlab = "visit", ylab = "x ULN", main = paste("Participant", su, "- lab trajectory"))
  }
  grDevices::dev.off(); message("NOTE [TRIALMON] evidence -> ", out)
}

tm_heartbeat <- function(study, status, n, fresh_ok, file = "out/heartbeat.txt") {
  writeLines(sprintf("%s %s %s SYSCC=0 N=%d FRESH=%d", study, status,
                     format(Sys.time(), "%Y-%m-%dT%H:%M:%S"), n, as.integer(fresh_ok)), file)
  invisible(TRUE)
}

# ---- example daily driver (schedule with cronR::cron_add / taskscheduleR) ------------
if (sys.nframe() == 0) {
  feeds  <- list(adlb = "/data/cp101/adam/adlb.xpt")
  fresh  <- tm_freshness(feeds)                                   # GATE first (NA-safe)
  adlb   <- tm_read(feeds$adlb)                                   # lowercased columns
  tm_guard(adlb, c("alt_x","ast_x","tbl_x","alp_x"), sanity = \(d) d$alt_x >= 0 & d$alt_x <= 400)
  hy     <- tm_chk_hyslaw(adlb)
  status <- tm_status(hy, fresh)
  dd     <- tm_dedup(filter(hy, sev == "RED"), key = "usubjid")
  if (nrow(dd$fresh)) {
    tm_evidence(dd$fresh, adlb)
    tm_email("biostat.cover@example.com", "RED ALERT - CP101 - safety threshold met",
             "A SCREENING flag met. Contact the medical monitor. Evidence attached.",
             attach = "out/evidence.pdf", urgent = TRUE)
    tm_commit_seen(dd)                                            # commit ONLY after the send
  }
  tm_heartbeat("CP101", status, n = nrow(adlb), fresh_ok = !any(fresh$stale, na.rm = TRUE))
}

Walkthrough, part by part

Header and honest-scope comment. The opening comment block names the file, states that it mirrors tm_macros.sas with no AI, and lays out the same six-stage loop the SAS version runs. It directs the biostatistician to schedule with cronR or taskscheduleR and to pin packages with renv so runs are byte-reproducible. It records the v2 corrections (strict Hy's-Law, NA-safe gate, %.0f, lowercase columns, deferred de-dup commit, fail-loud assert/guard) and states the honest scope: these functions detect and email deterministic flags but do not triage or judge, and the medical monitor decides. This framing is load-bearing because the deterministic R code owns every number and every check while a human owns every decision, which is the governance boundary the rest of the code is built to respect.

Package loading. A single suppressPackageStartupMessages call loads dplyr for the data manipulation verbs, haven for reading SAS transport files, gt for tables, and blastula for composing and sending email. Suppressing startup chatter keeps the unattended log clean so that real warnings stand out. Pinning these under renv (as the header instructs) is what makes a scheduled run reproducible over time.

tm_assert (fail-loud guard). tm_assert takes a condition, a message, and an optional backup recipient, and does nothing when the condition passes isTRUE. If the condition fails it writes a timestamped "CP101 FAILED" line to out/heartbeat.txt, optionally emails the backup recipient an urgent failure notice telling them not to assume GREEN, then stops the run with a clear error. This exists so an unattended job can never let "no news" read as "good news": a silent crash would otherwise look identical to an all-clear, so every downstream check routes its failures through this function.

tm_read (ingest and lowercase column names). tm_read first asserts that the extract file exists, then reads it with read_xpt and pipes the result through rename_with(tolower) to force every column name to lowercase. Because haven::read_xpt preserves the UPPERCASE CDISC names, lowercasing once here means every later check can reference alt_x, qtcf, or usubjid without worrying about source casing. This gives the whole library a single stable naming contract.

tm_guard (column presence, type, and range sanity). tm_guard takes a data frame, a vector of required numeric variable names, and an optional sanity predicate. It asserts that no required column is missing, asserts each required column is.numeric (catching a unit or type change), and if a sanity function is supplied asserts that it holds across the data with na.rm = TRUE to catch a possible unit error. It returns invisible(TRUE) on success. This guard is what stops a malformed extract from silently flowing into the clinical threshold checks and producing a wrong number.

tm_freshness (data-freshness gate, run first). tm_freshness takes a named list of feed paths and a maximum age in hours (default 26) and builds a tibble of each feed's file modification age. A feed is marked stale when its age is NA (an absent file) or exceeds max_age_h, and severity is set to AMBER when stale and GREEN otherwise. If any feed is stale it emits a warning naming the stale feeds and reminding the reader that GREEN on stale data is worse than RED. The NA-safe treatment is deliberate: a missing feed file is the worst case, so it must never pass the gate as if fresh.

tm_chk_hyslaw (Hy's-Law and eDISH screening). tm_chk_hyslaw flags participants meeting the corrected Hy's-Law pattern: (alt_x > altmult | ast_x > astmult) & tbl_x > bilmult with strict greater-than and default multiples of 3, 3, and 2 times ULN. It computes an r_ratio of alt_x / alp_x and classifies the pattern as hepatocellular, cholestatic, or mixed for context only, and it never lets ALP downgrade the RED flag. It filters to flagged rows and returns usubjid, a formatted flag string carrying the pattern and R value, and sev = "RED". This is a screening prompt for the medical monitor, not an adjudication of DILI.

tm_chk_qtcf (QTcF per ICH E14). tm_chk_qtcf grades cardiac risk from absolute qtcf and change-from-baseline dqtcf, defaulting to RED above 500 or 60 ms, AMBER above 480 or 30 ms, and GREEN otherwise. It keeps only non-GREEN rows and returns usubjid, a formatted flag, and the severity. The formatting uses %.0f rather than %d because QTcF and dQTcF are doubles, so %d would error or misprint; this is one of the v2 corrections.

tm_chk_enroll (enrollment KRI). tm_chk_enroll takes the enrolled and planned counts and a key risk indicator threshold (default 0.80), computes the fraction of plan, and returns a one-row tibble with a formatted flag and a severity of AMBER when the fraction is below the KRI and GREEN otherwise. It reports the percentage of plan and the KRI percentage in the flag text. This is an operational, non-safety signal, so its worst severity is AMBER rather than RED.

tm_status (overall roll-up). tm_status accepts any number of check-result data frames passed as variadic dots arguments, pulls each one's sev column, and returns "RED" if any severity is RED, otherwise "AMBER" if any is AMBER, otherwise "GREEN". This is the deterministic worst-case roll-up that decides the study-level color for the heartbeat. It encodes the rule that a single RED anywhere dominates the overall status.

tm_dedup and tm_commit_seen (deferred de-duplication). tm_dedup reads a persisted seen-store of keys (default state/tm_seen.rds), and returns a list with fresh (new findings whose key is not already seen), the full set of keys in the new findings, the store path, and the prior seen vector. tm_commit_seen then writes the union of the old seen keys and the new keys back to the store, and its comment says to call it ONLY after delivery succeeds. Splitting detection from commit is what guarantees that a finding is never marked "seen" unless its alert email actually went out, so a failed send re-alerts on the next run instead of going silent.

tm_email (notification). tm_email composes an email from Markdown body text with compose_email(body = md(body_md)), attaches a file when one is supplied and exists, and sends it via smtp_send using credentials from creds_file("smtp_creds"). When urgent is TRUE it prefixes the subject line with "[URGENT] ", and it logs a note after sending. This is the delivery mechanism the driver invokes before committing the seen-store, and it is also the channel tm_assert uses to escalate a monitor failure.

tm_evidence (per-RED evidence packet). tm_evidence takes the RED findings and the lab data and, if there is at least one RED row, opens a PDF and draws an eDISH scatter of alt_x against tbl_x from the lab data (axes labeled in x ULN) with flagged participants shown as filled points and reference lines at ALT=3 and TBili=2. It then draws, per flagged usubjid, a lab trajectory of alt_x, ast_x, tbl_x, and alp_x against visitnum before closing the device. This assembles the human-readable evidence a medical monitor reviews, and it returns early with no file when there are no RED participants.

tm_heartbeat (liveness and status record). tm_heartbeat writes a single line to out/heartbeat.txt recording the study, overall status, an ISO timestamp, SYSCC=0, the row count passed as n, and a FRESH flag coerced from a boolean with as.integer. Because a successful run always overwrites this file with a fresh timestamp, an external watcher can distinguish a healthy run from a stalled or crashed job (whose heartbeat would be old, or would carry the FAILED line written by tm_assert). It returns invisible(TRUE).

Example daily driver (main block). The block guarded by if (sys.nframe() == 0) runs at the top level (the intent per the header comment is a scheduled script run rather than being sourced as a library), and it wires the functions into order for CP101. It runs the freshness gate first, reads and lowercases adlb.xpt, guards the four lab columns with a range-sanity check that alt_x lies in [0, 400], runs the Hy's-Law check, and rolls up status by passing both hy and fresh to tm_status. It then de-dups the RED findings by usubjid, and only if there are fresh ones does it build the evidence PDF, send the urgent RED-alert email whose body says a SCREENING flag met and to contact the medical monitor, and commit the seen-store after the send. Finally it always writes the heartbeat with the row count and freshness flag; the ordering (send before commit, heartbeat always) is what keeps the unattended job honest.

Scheduling it: the whole setup, once

The token and any credentials are read at run time from an environment variable or a permissions-600 file, never hard-coded and never written to a log. Point the driver at your install (the %let paths and the config file), dry-run once, then schedule it under a dedicated service account, after your validated nightly extract refreshes.

  • SAS on Linux (cron): 0 2 * * * sas -nodms -sysin monitoring/monitor_driver.sas -log monitoring/log/monitor_$(date +\%F).log
  • SAS on Windows (Task Scheduler): a daily task running sas.exe -sysin C:\monitoring\monitor_driver.sas -log C:\monitoring\log\monitor.log
  • R: cronR::cron_add(command = cronR::cron_rscript("tm_companion.R"), frequency = "daily", at = "02:00"), or taskscheduleR::taskscheduler_create() on Windows. Pin packages with renv.
  • Dry-run first, then trust the heartbeat: the job fails loud on any error and emits a completion NOTE, so a stale run announces itself instead of masquerading as fresh.

Two-tier reporting & escalation

Entirely inside the validated environment — zero AI, zero new vendor, zero PHI egress.

Tier 1 — the scheduled digest

  • Daily safety-scan digest → the covering colleague: enrolled/dosed by cohort, new AEs/SAEs, labs out of range, ECG/QTc flags, cohort DLT tally, queries — with a green/amber status and a heartbeat. Keep a “no new flags” email firing too — silence must be a positive confirmation.
  • Weekly status pack → the cover (cc the lead’s inbox for the return): enrollment vs plan, escalation status, screen-fail, query backlog, deviations, visit compliance.
  • Weekly one-pager → the manager: one status light, enrollment %, count of safety flags this week and their disposition (open/closed/escalated). Strictly numeric — this is where “no AI narrative” is a feature: every number traces to a validated program.

Tier 2 — the threshold alert & escalation

  • Immediate urgent email when a pre-specified rule fires — a DLT-defining grade, an SAE, a QTcF crossing, a KRI breaching its QTL, a stopping-rule count. Distinctly flagged, outside the digest.
  • Severity routing. Tier A (SAE/DLT/stopping-rule/Grade-4): immediate to support + backup + role alias, with an explicit “CONTACT THE MEDICAL MONITOR NOW” and the MM’s name/number in the body. The system notifies and instructs whom to call — it does not contact the MM’s clinical decision for them, does not pause dosing, and does not adjudicate causality.

Delivery mechanics (pick what is in your validated environment)

StackHowGovernance note
SAS-nativeFILENAME out EMAIL / the EMAIL access engine — attach the ODS report, conditional send. No add-on packages.Over internal SMTP it keeps everything on-prem with no cloud token.
R-nativeMicrosoft365R (Graph) for Outlook/Teams/SharePoint, or blastula/sendmailR for richer HTML email.Microsoft365R routes through Graph/AAD (cloud auth) — fine inside the tenant; confirm it suits your data-handling posture.
Alert bodies carry de-identified signal only (e.g. “Cohort 3: 2 of 6 flagged for Grade≥3 ALT shift — review”), not participant-level PHI. Record-level detail stays in the attached report on the validated share, linked, not emailed.

Coverage while on leave — a month on Study CP-101

The deterministic system doing its one job: keeping Phase 1 monitoring alive and self-escalating for a month while the lead is unreachable in Europe — with zero AI and zero new tools.

What one scheduled run prints to the log

There is nothing to watch in a UI. The value is that every check and every decision is an auditable line in the job log, straight from the shipped %tm_* library. Here is a representative RED night (the interesting case, which exercises the whole path). Read it top to bottom.

# monitor_driver.sas, fired nightly by cron under a service account, after the validated extract refreshed
NOTE: [TRIALMON] init study=CP101 run=20260622T0200 out=/opt/trialmon/cp101/out
NOTE: [TRIALMON] latest sas7bdat in /data/cp101/adam -> adlb            # all 5 feeds current (<= 26h)
NOTE: [TRIALMON] Hy's-Law screen (SCREENING flag - ALP never downgrades a RED case)
NOTE: [TRIALMON] liver worst=RED run-status=RED                         # ALT 4.2x ULN with total bilirubin 1.6x: a prompt to look, not a diagnosis
NOTE: [TRIALMON] DLT tally vs 3+3 - flags the count; does NOT make the escalation call
NOTE: [TRIALMON] IXRS reconciliation - discrepancies escalate to DM/unblinded pharmacist, never auto-resolved
NOTE: [TRIALMON] evidence packet -> /opt/trialmon/cp101/out/CP101_evidence_20260622.pdf
WARNING: [TRIALMON] RED alert dispatched (primary+backup+alias)
NOTE: [TRIALMON] emailed "URGENT safety alert - CP101" to biostat-cover@example.com pm.cp101@example.com
NOTE: [TRIALMON] heartbeat written - external watcher (biostat.backup@example.com) alerts if it stops
NOTE: [TRIALMON] run complete - status=RED rc=0

# on a quiet night the same job prints run-status=GREEN, emails only the tier-1 digest, and writes the heartbeat; nothing escalates

Silence is never assumed: a failed run mails the fail-loud address, and the external watchdog alerts if the heartbeat stops.

The run, beat by beat

Freshness gate first, before any check. The job records the latest file for each feed and refuses to trust stale data. A feed older than the configured limit (26 hours) raises a WARNING, because a GREEN on stale data is worse than a RED. Only fresh feeds proceed to the checks.

Run the pre-specified deterministic checks. The liver Hy's-Law screen, QTcF, the 3+3 DLT tally, enrollment-vs-plan, query aging, and IXRS reconciliation each run as a pure computation in validated SAS/R and return a severity. Every one is a SCREENING flag, a prompt to look, never a diagnosis or an adjudication.

Worst-case roll-up decides the run status. The run is GREEN, AMBER, or RED by the worst single finding. On this night the liver screen met the pre-specified criteria (ALT 4.2x ULN together with total bilirubin 1.6x), so the run is RED. The AND-gate is the point: a high transaminase on its own does not trip it.

Tier-1 digest always, tier-2 urgent alert only on RED. Every run emails the daily digest with the status and the attached PDF. A RED run additionally dispatches an urgent alert to the covering biostatistician, the PM, and a backup alias, naming the medical monitor to contact and the 30-minute acknowledgement window. The job routes attention, it never adjudicates.

Evidence packet, then the heartbeat. The job writes a one-page evidence packet for the flagged finding, then a heartbeat that proves it ran. An external watcher alerts if the heartbeat stops, so silence is never mistaken for GREEN. The medical monitor and the covering biostatistician own every decision that follows.

Why it won’t fail silently

The SOP rule: “no news” is never interpreted as “all clear.” A successful, data-fresh run must say so explicitly, and the absence of any message is itself escalated.
Failure mode (while the lead is away)The SAS/R safeguard
The scheduled job dies silently — daemon down, server rebooted, expired service-account password, locked licence, full disk. No red email → assumed green → the trial runs unmonitored.Heartbeat / dead-man’s-switch. Every successful run emits a positive “ran OK, N records, SYSCC=0” ping; an independent watcher (a second tiny cron on a different host) escalates if the ping is missing.
Stale-data masquerade — the job runs green daily, but a feed silently stopped refreshing. Checks compute reassuring numbers on an old snapshot; a new SAE/DLT in the source is invisible. Green here is worse than red.Data-freshness gate, run first. Before any safety logic, compare each feed’s newest timestamp to now; if a source stopped updating, raise it. Put the freshness times in the report header.
Alert storm / fatigue — a mis-set threshold, a units mismatch (mg/dL vs µmol/L), a duplicate record, or one ongoing event fires the same RED dozens of times. The cover mutes the thread; the next real signal is buried.De-duplication. A persistent seen-flags table keyed by participant+test+event alerts once on first detection; rate-limit and escalate-on-change only.
Human single-point-of-failure — the one covering colleague is also on PTO/sick/offline, and the lead is asleep in a European timezone when a 06:07 RED fires. The alert lands in a void.Primary + backup + role alias, committed pre-departure, with an acknowledgement SLA (reply within 30 min) and a re-page / next-recipient rule if it isn’t acknowledged.

Governance, validation & honest limits

Why it is the lowest-risk pipeline

  • No new validation surface. The DLT tabulations, exposure summaries, lab-shift tables and counts it surfaces are produced by the same validated programs already qualified for the study. The lead is automating the delivery/cadence of validated outputs, not creating a new method.
  • Deterministic & reproducible. Same data → byte-identical result, every run. No sampling, no temperature, no model drift — nothing to “explain.” An LLM summary is plausible but not auditable line-by-line; a SAS/R alert is both.
  • Zero PHI egress. Everything runs where the data already lives — no external API, no cloud model, no new vendor. This sidesteps the entire BAA/HIPAA question that constrains the AI pipelines.
  • The automation itself is validated like any study program: the thin scheduling wrapper and the threshold logic get the team’s normal risk-based SDLC — spec, independent review, documented testing, change control.
  • Double-programming preserved. Anything formally reported (submission, SRC/DSMB pack, sponsor deliverable) is still independently double-programmed. Classify every output as REPORTABLE (QC’d before it goes out) or SIGNAL-ONLY (single-program, human-confirmed).

The honest limit

Deterministic checks only — no triage, no narrative, no judgment. SAS/R answers well-posed, pre-specified questions and emails the answer reliably. It does not weigh a borderline signal, summarize across signals in plain language, or brief the covering colleague on what a cluster means. That is exactly the seam the AI hybrid fills later — complementary, not competitive. This guarantees pre-specified signals reach a human reliably and reproducibly; it does not replace the human, the medical monitor, or the DSMB/SRC.

For the manager, in one line

Because there is no AI, no new vendor, and no PHI egress, there is no new approval gate between today and go-live — only the normal program-validation/change-control the lead already runs before any study output ships.

FAQ

How is this different from the AI pipelines? It is deliberately the opposite: deterministic, validated, no AI, no new vendor, no PHI egress — the layer you can ship today. The Claude+Local and Copilot+Local pipelines add the narrative-triage and knowledge layers on top, later.

Does it make clinical decisions? No. It detects pre-specified threshold crossings, pages the right people, and packages the evidence. The medical monitor and the covering biostatistician make every call. A flag is a prompt for a human, never a finding.

What if a job fails while the lead is away? The heartbeat / dead-man’s-switch and the data-freshness gate are designed so a dead job or a frozen feed announces itself — see Won’t fail silently. “No news” is never “good news.”

Do we need new software? No — use the SAS/R and the scheduler already validated in your environment. Introducing a new orchestrator just for this would re-add the governance cost you are avoiding.

Is the on-leave example the only use? No — that is the anchoring scenario, but this is the standing monitoring backbone for any study, any time. Coverage during leave is just where its value is most obvious.

Walkthrough transcript

The complete narration of the “Coverage while on leave” video, as readable text.

1. Part 1

Here's the third companion pipeline — the one you can ship today. It uses strictly SAS and R, no AI, to keep trial monitoring running while the lead biostatistician is away for a month. Let me walk through a real month on Study CP-101: how it's set up, what the covering colleague and the manager receive, and what happens when a real safety signal fires — all inside the existing validated environment.

2. Part 2

Before leaving, the lead doesn't write new code — they schedule the monitoring programs that already run on demand. Five jobs: a nightly data refresh with a freshness check, a morning safety scan, a daily digest to the covering colleague, a weekly status pack plus a one-page summary for the manager, and an on-event threshold alert. It all runs under a service account that outlasts the lead's login. And a one-page coverage runbook goes to the support and the manager: what green, amber, and red mean, who does what, and the honest note that a flag is a number crossing a line — not an interpretation.

3. Part 3

Week one is quiet. Every morning the covering colleague gets the digest — enrollment, new adverse events, liver and QT flags, the cohort DLT tally, queries — all green. The most important line is at the bottom: the heartbeat. It confirms the job ran, on data fresh as of this morning, with no errors. That's the trick to an unattended month: silence is never assumed. A missed job or a frozen feed announces itself, instead of hiding behind a green screen.

4. Part 4

Week two, an operational signal. Enrollment slips below its KRI floor — seventy-six percent of plan, with a screen-failure cluster at one site. The digest renders that row amber. Notice where it goes: it stays in the routine channel. The covering colleague reviews it and raises a site action with the project manager — it does not cry wolf at the medical monitor. And the manager's Friday one-pager simply shows it was flagged and is being handled.

5. Part 5

Week three, a real safety signal. Overnight, a participant in the top dose cohort shows a candidate Hy's-Law lab pattern — ALT over three times the upper limit, and bilirubin over twice it. This trips the urgent tier, outside the digest, immediately — to the support, a backup, and a role alias, never just one person. The email carries the medical monitor's number, a thirty-minute acknowledgement requirement, and, crucially, it's honest that this is a screening flag that matches the lab criteria — not a diagnosis. The medical monitor adjudicates.

6. Part 6

And it doesn't just say something happened — it attaches the evidence, already assembled: the eDISH plot with the participant in the Hy's-Law quadrant, the lab trajectory, the dosing timeline, and the exact rule that fired. On the right is the escalation log, time-stamped automatically: the alert fired at six-oh-seven, was delivered, acknowledged within the SLA, reviewed by the medical monitor — who ordered repeat labs and held dosing — and by Sunday the signal didn't confirm and the cohort was released. The system did three deterministic things: detected, paged, packaged. Every clinical decision was the medical monitor's.

7. Part 7

Week four, the lead is back. Waiting for them is a complete, time-stamped archive: every daily log, every digest with its heartbeat, the two notable alerts and how they resolved, the auto-built evidence packet, and a heartbeat history proving every run was accounted for. Because the whole system is deterministic and validated, there's nothing to reconstruct — no question of what an AI decided and why, no PHI egress to explain. The loop closed itself: flag, escalate, package, decide, stand down.

8. Part 8

The reason a manager can trust this is that it's designed so it can't fail silently. Four safeguards. A heartbeat with an independent watcher, so a dead job announces itself. A data-freshness gate that runs first, so a clean green on stale data — which is worse than red — gets caught. Alert de-duplication, so one real event alerts once instead of a storm that buries the next signal. And primary plus backup recipients with an acknowledgement requirement, so a six a.m. alert never lands in a void. The rule in the SOP: no news is never interpreted as good news.

9. Part 9

So, the bottom line. This is the lowest-risk of the three pipelines — no AI, no new vendor, no PHI egress, no new validation surface — so there's no approval gate between today and go-live. It's deterministic and reproducible, so the audit trail is automatic. It detects, pages, and packages; the humans and the medical monitor still make every call. And it's necessary, not sufficient: the narrative-triage layer is exactly the seam the AI hybrid fills later. Ship this now; add AI on top when it's approved. The peace of mind is simple — monitoring never stops, even silence is verified, and every escalation is automatic and logged, while the lead is unreachable in Europe.