AI for Biometrics

Coverage while on leave — a SAS/R playbook

Going on leave for a month? This playbook sets up deterministic, automated trial monitoring in SAS and/or R so monitoring never stops, the covering colleague gets the right updates, escalation is automatic, and your manager has peace of mind — with no AI, no new tools, and no PHI egress, all inside the existing validated environment.

The honest framing. This detects, packages, and emails pre-specified deterministic flags — counts, thresholds, deltas, run failures — to a human, reproducibly. It does not triage, interpret, or judge. A flag is a prompt for a human; the medical monitor and the covering biostatistician make every call. Necessary, not sufficient — and that is the point: it’s the layer you can ship today.
On-leave coverage pipeline: deterministic scheduled SAS/R from scheduler to email digest, branching to Tier-2 alert and heartbeat. Coverage pipeline Deterministic · no AI · humans decide Scheduler cron / Task Sched. Ingest latest data FRESHNESS GATE stale never looks fresh Safety + ops checks pre-specified Severity roll-up Email digest Tier-2 alert on any RED Heartbeat written proves the job ran
The whole loop, deterministic and no-AI. The freshness gate runs first, so a clean green on stale data can never slip through; any RED branches to the urgent tier; and every run writes a heartbeat, so silence is never assumed.
Accountability does not go on leave. The automation covers the vigilance and the logistics — running the checks on schedule, paging the right people, packaging the evidence — but it never covers the decision. The covering biostatistician and the medical monitor own every call; this only makes sure the signal reaches them.
Leave is one scenario of five. The rules that keep monitoring alive while one person is away (a service account, a named backup, a heartbeat with an independent watchdog, and “no news is never all clear”) also cover an unplanned absence, a role handoff or departure, onboarding a new owner, and a standing bus-factor review. See Five scenarios. To list your team’s recurring jobs and get a coverage matrix, a risk register, per-job checklists and a printable handoff packet, open the interactive Continuity Toolkit.

What this playbook gives you

  1. A pre-departure checklist — schedule the already-validated programs under a service account.
  2. A two-tier notification design — a daily digest + an urgent threshold alert — with a heartbeat so silence is never assumed.
  3. A ready macro library (tm_macros.sas + a driver, and an R companion) for maximum ease of automation.
  4. A coverage runbook to hand the covering biostatistician and your manager.
  5. A job inventory and bus-factor review for the whole team, plus an interactive toolkit that turns the inventory into handoff checklists and a handoff packet.

See it run for a full month in Watch it work.

Five scenarios, one set of rules

A recurring job breaks in the same few ways whatever the reason its owner is unavailable. It runs under a login that has stopped working. Its alerts go to one inbox. It fails without anyone hearing. Or only one person knows how to run it. The safeguards in this playbook fix all four, so the same toolkit covers every reason an owner might be unavailable.

ScenarioTriggerLead timeWhat changes compared with planned leave
1. Planned leaveHoliday, parental leave, a conference, a sabbatical.WeeksNothing: this is the original flow. See Before you leave and Coverage runbook.
2. Unplanned absenceIllness, an emergency, or someone who simply can’t be reached.NoneNo handover is possible, so you start by checking: did today’s run happen, where are the runbook and the scheduler entry, what is due in the next 72 hours. Then re-point recipients to the stand-in.
3. Role handoff / departureA role change, a reorganisation, someone leaving the team.Days to weeksOwnership moves permanently. The scheduler entries, accounts and recipient lists move. The receiving owner does a shadow run and then a solo run. The departing person’s access comes off last.
4. Onboarding a new ownerA new team member, or spreading the load across the team.WeeksNobody is absent. The new owner reads, shadows, reverse-shadows, handles one AMBER, and is signed off as a named backup.
5. Bus-factor reviewQuarterly, and before any planned leave.None neededA health check across the team: which jobs only one person can run, and which critical jobs run under a personal login or have no heartbeat or runbook.

Unplanned absence: the first hour

  1. Check the heartbeat. Read heartbeat.txt or the watchdog mail (tm_watchdog.sh). A missing heartbeat is a RED condition, not a quiet day.
  2. Find the job. Locate the program, the scheduler entry and the runbook, and write down where each one is. If the job runs under the absent person’s own login, expect it to stop soon. Moving it to the service account comes first.
  3. Re-point the recipients in tm_config.sas (CFG_SUPPORT, CFG_BACKUP, CFG_ALIAS), under normal change control, so the next digest reaches the stand-in.
  4. List the next 72 hours: scheduled runs, data cuts, deliverables. Tell the manager and the study team who is covering.
  5. Log every manual action. The log is the handback when the owner returns.

Role handoff or departure: the order matters

  1. Knowledge transfer: the code, the inputs, the outputs, the failure modes seen so far.
  2. Move the scheduler entry to the receiving owner or a service account, and re-issue credentials. Never pass on a personal password.
  3. Update recipients, the role alias and the escalation contact in config.
  4. Shadow run (the receiving owner runs the job while the departing owner watches), then a solo run (the receiving owner runs it alone and the departing owner checks the output).
  5. Update the runbook and the job inventory.
  6. Only then remove the departing person’s access and take their inbox off the recipient lists. Revoking first is how a handoff turns into an outage.

Onboarding a new owner

Read → shadow → reverse-shadow → handle a GREEN and an AMBER digest (or a rehearsed RED) → added as a named backup in tm_config.sas and the inventory → signed off by the current owner. Recovery is the skill that matters. Running a green job is not.

Accountability transfers explicitly or not at all. In every scenario someone signs that they have received the job. The toolkit’s handoff packet has an outgoing/incoming/date line on every job sheet for that reason.

Before you leave — the pre-departure checklist

You don’t write new code — you schedule the monitoring programs that already run on demand.

Weekly schedule timeline: recurring daily jobs, a Friday weekly roll-up, and event-driven alert and watchdog checks. Schedule Daily routine Weekly Alert Watchdog Mon 00:00 Fri end 02:00 Refresh + freshness gate 06:00 Safety scan 07:00 Digest sent repeats every weekday Fri 16:00 Weekly roll-up + one-pager on-event Tier-2 alert +15 min watchdog checks heartbeat
The unattended week at a glance — the daily routine jobs, the Friday manager roll-up, and the two event-driven safeguards: the alert, and the watchdog that checks the heartbeat 15 minutes later.
✓ItemWhy
□Run everything under a service / functional account (e.g. SVC-BIOSTAT) — not your personal login.Your credentials/VPN go idle on leave; a personal-owned job dies the moment they do. The #1 silent-failure cause.
□Confirm the service account has the SAS licence, share access, and SMTP relay rights.Verify before you go, not after a missed run.
□Schedule the jobs (Task Scheduler / cron): 02:00 refresh+freshness, 06:00 safety scan, 07:00 digest, Fri 16:00 weekly + manager one-pager, on-event alert.Moves them from “run when I remember” to “run on a clock.”
□Set thresholds (KRIs/QTLs/DLT/Hy’s-Law/QTcF) to the protocol’s pre-specified values.Deterministic flags must be pre-specified to stay defensible.
□Set recipients: primary + backup + a role alias; the medical monitor’s name/number; the PM; cc your inbox for the return.So a 6 a.m. alert never depends on one inbox.
□Hand over the coverage runbook (PDF) to the covering biostatistician, cc the manager.Plain-language GREEN/AMBER/RED, who does what, the honest limit.
□Do a dry run the week before; confirm the heartbeat and a test alert arrive.Prove the whole loop fires before you’re unreachable.
One rule when you set thresholds: use the protocol’s pre-specified values — and never widen a safety threshold just to silence a noisy page. If an alert fires too often, fix the check or the routing, not the safety bar.

When you return — the hand-back

Re-entry is a checklist too, not a glance at the inbox.

✓On return
□Read the escalation log end to end — confirm every AMBER/RED and exactly how it resolved.
□Check the heartbeat history — every run accounted for, and any gap explained, not silently missed.
□Re-assign backup= from the cover back to you; restore the recipient list.
□Close out any open AMBER/RED items with the PM and the medical monitor.
□Debrief the covering biostatistician — note anything to tune (thresholds, routing) for next time.

Watch it work — a month on Study CP-101

The deterministic system keeping Phase 1 monitoring alive and self-escalating for a month while the lead is unreachable in Europe.

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.

Walkthrough transcript

The complete narration of the “Watch it work” video, as readable text — an alternative to the video and a captioned reference.

1. Intro

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. Before leaving — the setup

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. Week 1 — the GREEN digest & heartbeat

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. Week 2 — the AMBER enrollment dip

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. Week 3 — the RED urgent alert

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. Week 3 — the evidence & escalation

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. Week 4 — the audit trail

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. The four safeguards

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. The bottom line

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.

The coverage runbook

The one page you hand the covering colleague (the covering biostatistician), cc the manager. Download the full runbook (PDF).

StatusMeansWho does what
GREENAll checks within limits.No action. The heartbeat still confirms the run happened on fresh data.
AMBERAn operational/quality KRI crossed.The covering biostatistician reviews & acts (e.g. a site action with the PM). Stays in the routine channel.
REDA safety threshold (DLT/SAE/Hy’s-Law/QTcF).The covering biostatistician acknowledges ≤30 min, then CONTACTS THE MEDICAL MONITOR. Evidence packet attached.
Four-rung escalation ladder from all clear to no heartbeat. Escalation ladder lowest → highest GREEN · All clear No action — the daily heartbeat proves the job ran AMBER · Watch Covering biostatistician acts — routine channel RED · Alert Acknowledge in ≤30 min → medical monitor decides · evidence packet attached SILENCE · No heartbeat Independent watchdog escalates to RED and pages the backup
Four rungs. GREEN is the heartbeat proving the run happened on fresh data; RED demands a 30-minute acknowledgement and the medical monitor; and SILENCE — no heartbeat at all — is itself escalated to RED.
If you receive no email: “no news” is never “all clear.” A successful run always sends a GREEN digest with a heartbeat. If the 07:00 digest doesn’t arrive by 09:00, treat the system as down and notify IT / the backup. A silent system is a RED condition.

Job inventory & bus factor

Coverage starts with a list. Every recurring job the team runs (monitors, refreshes, reconciliations, dry-run batches, data-cut packages) gets one row. You can’t cover a job you don’t know exists.

One row per job

FieldWhy it’s there
Owner and backupsThe number of distinct people who can run a job is its bus factor. One person means one point of failure.
Cadence and scheduleTells the cover when to expect output. Also sets how old the last run can get before the job counts as stale.
Inputs and outputsSo the cover can tell a failed job from an upstream feed that hasn’t refreshed (the freshness gate’s job).
CriticalityHigh means participant safety, or a regulatory or contractual deadline, depends on it. High jobs with a single owner are RED.
Last run and last statusTaken from the heartbeat file, never from memory. tm_inventory.sh reads the heartbeats and writes a status CSV.
Runs asA service or functional account, or a personal login. A personal login is the most common cause of a silent failure.
Heartbeat + watchdogWhether a dead job will tell anyone it has stopped.
Runbook linkThe one page the cover reads at 06:05.

Download the CSV template (fictional example rows). The interactive toolkit imports and exports this same format.

The risk rules

RuleSeverityFix
No primary ownerREDAssign one, or retire the job and record that decision.
Nobody can run it in this scenarioREDName a cover now and dry-run the job with them.
Single owner (bus factor 1)RED if High, else AMBERName a backup and have them run it once.
Runs under a personal loginRED if High or if the owner is the one away, else AMBERMove it to a service account.
Stale: last run older than the cadence allowsRED if High, else AMBERConfirm it is still scheduled, then rerun on fresh data.
Last run FAILEDREDFix and rerun before any handoff.
Covered only by someone who isn’t a trained backupAMBERWalkthrough plus a dry run before the start date.
No heartbeat or watchdog, or no runbookAMBER if High, else INFO%tm_heartbeat + tm_watchdog.sh; a one-page runbook.
One person is the only one who can run 2+ High jobsRED (team-level)Spread the backups.

Refresh last-run status from the heartbeats

# jobs.list: one "JOB_ID  heartbeat-path" per line
DEMO101-SAFETY   /opt/trialmon/demo101/out/heartbeat.txt
DEMO202-REFRESH  /opt/trialmon/demo202/out/heartbeat.txt

$ ./tm_inventory.sh jobs.list > heartbeat_status.csv
id,last_run,last_status
DEMO101-SAFETY,2026-10-19,AMBER
DEMO202-REFRESH,2026-10-21,FAILED

Import heartbeat_status.csv on the toolkit’s Scenario tab. A CSV with no name column only updates last_run and last_status on matching job IDs; a missing heartbeat file shows as FAILED. The script only reads: it never touches the monitor, the heartbeat or the scheduler. tm_inventory.sh

The interactive Continuity Toolkit

A single self-contained page that turns the job inventory into the coverage view for whichever scenario you pick.

  1. Scenario: pick one of the five, then name the person and the cover and set the dates.
  2. Jobs: add, edit, or import from CSV or JSON. Each job has owner, backups, schedule, inputs, outputs, criticality, last run, runbook link, run-as account and heartbeat.
  3. Coverage matrix: jobs × people. P = owner, B = backup, C = the scenario’s cover where they are not a trained backup. The column for whoever is out is hatched. Each row shows who actually runs the job during the scenario.
  4. Risks: the register above, plus “what if each person were out?”
  5. Checklists: one per job, made from the scenario’s steps plus that job’s own fixes. Ticks are saved.
  6. Handoff packet: print or save as PDF, or download as .html or .md. It has a cover sheet, the GREEN/AMBER/RED key, the risks to close, a coverage summary, and a job sheet with a sign-off line for every job.
The Continuity Toolkit's coverage matrix for the fictional example: eleven jobs down the side, five fictional people across the top, P and B markers for owner and backup, the absent person's column hatched in red, and a 'during scenario' column showing who runs each job, one of them uncovered.
Coverage matrix, planned-leave scenario, fictional example. One job has no owner, so nobody covers it. The IXRS reconciliation falls to a cover who has never run it.
Private by construction. Data stays in your browser (localStorage) and moves only when you export it as JSON. The page loads nothing from any server: no CDN, no fonts, no analytics. A Content-Security-Policy blocks network requests outright. The example is fictional and is labelled as such on every packet it prints.

The macro library — maximum ease of automation

A deterministic, no-AI SAS macro library (with an R companion) that implements the whole loop: ingest → freshness gate → checks → roll-up → digest, plus a tier-2 urgent alert and a heartbeat. Map the placeholder column names to your validated ADaM/SDTM specs.

Quick start

%include "tm_macros.sas";
%tm_init(study=CP101, root=/opt/trialmon/cp101, smtp=smtp.internal, statelib=.../state);
%tm_freshness(feeds=adam.adlb adam.adeg adam.adae, maxage=26);   /* GATE first        */
%tm_chk_hyslaw(in=adam.adlb_edish);   %tm_status(in=_tm_hyslaw); /* deterministic flag */
%tm_chk_qtcf(in=adam.adeg);           %tm_status(in=_tm_qtcf);
%tm_chk_enroll(in=adam.enroll, kri=0.80);
%tm_digest(title=CP-101 Daily Safety Digest, sections=_tm_enroll _tm_qtcf _tm_hyslaw, to=&support);
%tm_alert(in=_tm_red_new, to=&support, backup=&backup, alias=&alias,
          mm_name=%str(Dr. Okafor), mm_phone=+1-555-0100, evidence=&packet);
%tm_heartbeat(records=&n, watcher_to=&backup);

Schedule it (service account)

:: Windows Task Scheduler
schtasks /create /tn "CP101_monitor" /tr "run_monitor.bat" /sc DAILY /st 06:00 /ru SVC-BIOSTAT
# Linux cron
0 6 * * 1-5  /opt/jobs/run_monitor.sh    # sas -sysin monitor_driver.sas -batch

The macros cover the safety checks (Hy’s-Law/eDISH, QTcF, AE/SAE, DLT-vs-3+3, labs), operational checks (enrollment KRI, query aging, visits, IXRS reconciliation), the two-tier digest/alert, and the four safeguards (heartbeat, freshness gate, de-dup, primary+backup). See the README in the macro library for the full catalog.

Below is the complete, actual program, every line: the config you edit, the library it calls, the driver you schedule, and the R twin. These are the real shipped files, also available as downloads.

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

Why it won’t fail silently

The SOP rule: “no news” is never “all clear.” A successful, data-fresh run must say so explicitly; the absence of any message is itself escalated.
Dead-man's-switch heartbeat flow across two separate hosts. Dead-man's switch watcher lives on a separate host Monitoring host runs the scheduled job writes heartbeat file timestamp + status after every run heartbeat separate hosts Independent watcher second host / service reads the heartbeat on its own schedule Present, recent, status = OK? YES Stay quiet job is healthy NO missing / stale / FAILED Page backup · treat as RED
The dead-man’s switch. The watcher lives on a separate host, so a dead monitoring machine cannot quietly hide its own failure — missing, stale, or failed, the heartbeat pages the backup.
Failure modeThe safeguard
The job dies silently (daemon down, expired password, locked licence, full disk).Heartbeat / dead-man’s switch — every run pings “ran OK”; an independent watcher on a second host escalates if the ping is missing.
Stale-data masquerade — green daily, but a feed stopped refreshing. Green here is worse than red.Data-freshness gate, run first — compare each feed’s newest timestamp to now; raise it if a source stopped.
Alert storm — a mis-set threshold or duplicate fires the same RED dozens of times; the cover mutes the thread.De-duplication — a persistent seen-flags table alerts each finding once.
Human single-point-of-failure — the one cover is also away when a 06:07 RED fires.Primary + backup + role alias with an acknowledgement SLA and a re-page rule.

Governance & honest limits

Why this is the lowest-risk option

  • No new validation surface. It surfaces outputs from the same validated programs — it automates the delivery/cadence, not the method.
  • Deterministic & reproducible. Same data → byte-identical result, every run. Nothing to “explain”; the audit trail is automatic.
  • Zero PHI egress. Everything runs where the data already lives — no external API, no cloud model, no new vendor. (Alert bodies carry de-identified signal only; detail is in the attached report.)
  • Validated like any study program (spec → review → test → change control); anything reported stays independently double-programmed. A flag is a prompt, not a result — every reported number comes from the validated tools (Phoenix WinNonlin, Pinnacle 21, the double-programmed SAS/R), never from a monitoring flag.
  • No new approval gate between today and go-live — only the normal program validation you already run.
The honest limit: deterministic checks only — no triage, narrative, or judgment. It guarantees pre-specified signals reach a human reliably and reproducibly; it does not replace the human, the medical monitor, or the DSMB/SRC. That triage layer is the seam the AI hybrid fills later.

FAQ

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

Does it make clinical decisions? No. It detects threshold crossings, pages the right people, and packages the evidence. The medical monitor and covering biostatistician decide.

Is it only for leave coverage? No. Leave is the first of five scenarios: planned leave, unplanned absence, a role handoff or departure, onboarding a new owner, and a standing bus-factor review. The monitoring backbone runs for any study, any time, and the Continuity Toolkit covers the people side: who can run what.

Where does the job inventory live? Anywhere you like. The toolkit keeps it in your browser and exports JSON or CSV. Many teams keep the CSV next to tm_config.sas under version control and refresh last_run from the heartbeats with tm_inventory.sh.

What if a job fails while I’m away? The heartbeat and freshness gate make a dead job or a frozen feed announce itself. “No news” is never “good news.”