Sapling SAP
Reference

Customize: the template and the programmer's hooks

Where the programmer adds what the generated programs cannot say, in files the generator never writes.

This page is the repository's own document docs/customize.md, shown as it is, so that it never drifts from the product.

The SAP makes the ADaM specification, the ADaM programs and the TLF programs. They are a template: close, not always complete. The programmer adds what the template cannot say, in files the generator never writes. Every piece of custom code is named, bound to its SAP block, measured against the generated result, and shown to the reviewer.

First choice: the spec, not a hook. Most changes of what Sapling makes belong in the specifications, adam-spec.qmd and tlf-spec.qmd (sapling adam spec, sapling tlf spec): a variable's label or length, a derivation rule, the order of a table's rows. The SAP says what, the spec says how; a spec rule that differs from the SAP is reported by sapling check until sapling sap sync writes it into the SAP for a person to accept, and an SAP change is merged into your spec 3-way (docs/omakase.md, "How to change what Sapling made"). The hooks below are for what neither the SAP's rule language nor a spec can say; the SAP declares each with its words (::: {.derive variable="..." rule="..." custom="..."}).

Why#

sapc adam writes one R program per ADaM data set; r-agentic writes one three-line program per output. Both are made again after each change of the SAP. Without a place for the programmer's code there are two bad choices: a new Sapling command for each special case, or hand edits that the next generation overwrites. The "generation gap" fixes this: the generated code and the programmer's code live in different folders, and the generated code calls the programmer's code at named places.

The two folders#

FolderWho writes itWhen it changes
the generated folder: out/adam/ (sapc adam --out DIR): programs/<ds>.R, specs/adam/, define.*, hooks.jsonsapc adam onlyalways made again from the SAP; a program it did not write this time is removed
custom/ next to the plan (--custom DIR): custom/<ds>.R, custom/tlf/<output>.Rthe programmernever by sapc; sapc adam refuses an --out inside it

In a study of r-agentic the generated programs are programs/adam/ and the custom code is studies/<study>/custom/ (the same files). The submission package has both (below).

Rule 1. Never edit a generated file. Change the SAP, or write a hook in custom/.

Rule 2. A plan without a custom/ folder and without custom= gives the same files, byte for byte, as before this feature.

The steps and their ids#

Each step of a generated program comes from one block of the SAP, and the step has the id of that block:

SAP commandStep idVariables it makes
::: {.dataset name="ADSL" label="..." structure="..."}... (the records and every block copyvars)ADSLthe copied variables
::: {.derive variable="ADSL.TRT01PN" rule="..."}, flag, cut, pooling, baseline, changefrombase, rangeindicatorADSL.TRT01PN (the variable)the variable (a block cut also its codes=)
::: {.parameters dataset="ADQSADAS"}ADQSADAS.PARAMSPARAMCD, PARAM, PARAMN, AVAL
::: {.windows datasets="ADQSADAS"}ADQSADAS.WINDOWSAVISIT, AVISITN, AW*
impute, addrecords, relativechangeADQSADAS.IMPUTE1, ... (as the block id)records
::: {.parameter parameter="ADQSNPIX.NPTOTMN" rule="..."}, ::: {.timetoevent parameter="ADTTE.TTDE"}ADQSNPIX.NPTOTMN, ADTTE.TTDErecords

The generated program shows the id in the head comment of each step (# SAP 7.1.4 ... step ADSL.AGEGR1). sapc json lists the ids (the blocks of kind adam).

The simple form: custom_step()#

One call says custom code. Its place is one argument; the five hook_*() calls below are the same thing in the long form and stay valid.

# custom/advs.R
# drafted by: agent (2026-11-13)
custom_step("advs_units", after = "start", function(data) {          # early processing: right after the records
  data$VSSTRESN <- ifelse(data$VSSTRESU == "kPa", data$VSSTRESN * 7.50062, data$VSSTRESN)
  data
})
custom_step("advs_site9", after = "ADVS.BASE", expect = c("BASE"), function(data) data)   # between two steps
custom_step("advs_chgcat", function(data) { data$CHGCAT1 <- "..."; data })   # the variable of custom="advs_chgcat"
custom_step("advs_cleanup", after = "end", function(data) data)      # late processing: before the data set is written
PlaceWhen it runsThe long form
after = "start"early processing: after the first step (the records and the copied variables)hook_after("<DS>")
before = "<step id>", after = "<step id>"before or after that step of the SAPhook_before(), hook_after()
instead = "<VAR>"after the step of the variable: its generated rule is replaced; only the variables of that step may change, the records stayhook_override()
no placeat the derive block of the SAP with custom="<id>": it makes that variable and nothing elsehook_add()
after = "end"late processing: after the last step, before the data set is writtenhook_filter() (which may only remove records; a custom step here may also change variables)

So the order of a data set is: the records, early custom code, the steps of the SAP in their order (each with its custom code before, instead and after), late custom code, the data set.

The hooks of an ADaM data set: custom/<ds>.R#

One file per data set, named by the data set in lower case. The file only registers functions; the generated program runs them at their place. Each function gets the data (a data frame) and gives back the data.

# custom/adsl.R: the programmer's code for ADSL (docs/customize.md). sapc never writes this file.
# approved by: (a person writes the name and the date after the review; never an agent)

# drafted by: agent (2026-11-10)
hook_override("AGEGR1", function(data) {
  data$AGEGR1 <- ...            # replaces the SAP's derivation of AGEGR1 (and of AGEGR1N, the cut block makes both)
  data
}, input = "3f2a9c1e")

hook_add("adsl_dur", function(data) dplyr::mutate(data, DURMAX = ...), input = "77b0c2d4")
hook_after("ADSL.SAFFL", function(data) data, input = "0c1d2e3f")
HookRunsIt may changeIt must not
hook_before("<step>", f)before the stepanything (the effect is measured)—
hook_after("<step>", f)after the stepanything (the effect is measured)—
hook_override("<VAR>", f)after the step that makes VAR, on its resultonly the variables of that stepchange the records; override a copied variable (use hook_after("<DS>")) or a step that makes records
hook_add("<hook id>", f)at the place of ::: {.derive variable="..." rule="..." custom="<hook id>"}add the variables the SAP declares with that hook idchange another variable or the records
hook_filter("<name>", f)at the end, before sma_finalize()remove recordsadd records or variables

A variable that a hook adds and the SAP does not declare is a work variable: sma_finalize() drops it. A new variable of the data set is always declared in the SAP (custom=, below), so the specification, the define table and the analyses know it.

input = "<fingerprint>" pins the hook to the generated input it was written for (see "Hook drift"). sapc adam and sapc custom-report print the fingerprint to write.

Rule 3. The generator checks every hook: an id that is no step of the data set, a variable that no step makes, a hook_add without a custom= in the SAP, a custom= without a hook: each is an error of sapc check and sapc adam, with the fix. A hook is never dropped in silence.

Logic the rule language cannot say: custom= in the SAP#

::: {.derive variable="ADSL.INTMAX"
    rule="the longest gap in days between the end of one exposure record and the start of the next, from EX (protocol 3.6.2)"
    custom="adsl_dur" type="N" label="Longest Treatment Interruption (days)"}
:::
::: {.derive variable="ADSL.AGEMIN18" rule="the age, with ages under 18 set to 18" r="pmax(AGE, 18)" type="N"
    label="Age, at least 18"}
:::

The same hooks in SAS: custom/<ds>.sas#

The SAS programs of the ADaM (sapling sas adam programs, sas.md) call the hooks of custom/<ds>.sas: each a macro named for its kind and its place (%macro hook_after_adqsadas_windows(data=, input=3f2a1b4c);, hook_override_durdis, hook_add_adsl_wtloss, hook_filter_screen), with the working data set as data= and the pinned fingerprint as the default of input=. The same code binds them to their steps, with the same errors, the same fingerprints, the same drift and the same marks; the custom report lists the hooks of both languages. A hook of R without its twin in SAS makes its step NOT MADE in SAS, and the message names the macro to write; the R programs never call the SAS file.

The hook of an output: custom/tlf/<output>.R#

The numbers of a table still come only from the ARD. A layout hook changes how the table shows them: the order of the blocks and rows, the labels, the footnotes, a row split into more rows. It gets the table that ars_table() made (tab: title, population, footnotes, column heads, rows, the place of every result) and gives it back.

# custom/tlf/t14_2_01.R
hook_layout(function(tab) {
  tab <- tlf_move_block(tab, "Sex", before = "Age (years)")
  tab <- tlf_split_row(tab, "Mean (SD)", c("Mean", "SD"))
  tlf_footnote(tab, "Sex is shown first at the request of the medical writer.")
}, input = "5be1a0c2")

After the hook, r-agentic draws every cell again from the results of the ARD by their key: a hook moves results, it cannot type a number. Every result of the ARD keeps exactly one place (none lost, none twice), else ars_output() stops. The listing, its ARD file and the dashboard page use the same hook, so the data gate (every number of the listing is a result of the ARD) and the dashboard gate (every number on screen is the ARD's, once) count it.

Hook drift: after an SAP amendment#

sapc adam makes the generated folder again and keeps custom/ as it is. For each hook it computes the fingerprint of the generated input of the hook: the generated code and the SAP data (rule, windows, parameters) of the steps the hook depends on, and of the data sets the data set reads:

HookIts input
hook_before(S)the steps before S
hook_after(S), hook_override(VAR of S)the steps up to S, S included (an override sees a change of the rule it replaces)
hook_add(id)the steps before its block derive, and the words, type and r= of that block derive
hook_filterevery step of the data set
hook_layout (an output)the output's block and its analyses in the SAP

The report hooks.json and custom_drift.txt in the generated folder (and sapc custom-report) say for each hook:

StatusMeaningWhat the programmer does
OKits pinned input = is the fingerprint nownothing
DRIFTthe generated input changed since the pinread the change of the SAP, change the hook if needed, then write the new input =
NOT PINNEDno input =review it, then write input =
GONEits step or variable is no longer in the SAPan error: sapc adam fails until the hook is moved or removed

A drifted hook still runs (the programmer's code is not dropped), but not in silence: sapc adam names it, and the generated program passes the fingerprint to sma_custom_apply(), which warns: the run is RESULT: CHECK, not CLEAN, until the programmer reviews the hook and writes the new pin.

The custom report: what is not generated from the SAP#

node src/cli.ts custom-report plan.qmd [--custom DIR] [--run DIR] [--compare FILE] [--out DIR]

One row per hook and per custom= variable: the file and line, the kind, the SAP block and variable it touches (id, SAP section, plan line), its fingerprint status, who drafted it (# drafted by: agent or a person), whether a person approved it, its last change (date and SHA-256 of its code), and its effect: what it changed against the generated result, from two sources:

Verdict: CUSTOM REPORT PASS (every hook bound, pinned, approved), CUSTOM REPORT OPEN (bound, but a hook drifted, is not pinned, or waits for a person's approval), CUSTOM REPORT FAIL (a hook or a custom= does not resolve). The same list is a section of sapc review and an appendix "Custom code" of the PDF.

Rule 4. Custom code that an AI agent writes carries the line # drafted by: agent (...) above the hook (or at the top of the file, separated from the first hook by an empty line: then it marks every hook of the file) until a person reviews it; only a person writes # approved by:. Nothing approves a hook automatically: the custom report stays OPEN for a drafted or unapproved hook.

The proofs still apply#

The customized result goes through the same gates as the generated one: the ADaM data sets are checked by sma_finalize() against the specification of the SAP; the TLFs through the whole chain (the ARD, the CSR comparison, the dashboard gate); the data gate on other data; the listings against the previous run with a diff report. zsh tools/custom_proof.sh proves it on the pilot (below). Without a custom/ folder every proof of Sapling and r-agentic gives what it gave before.

The submission package#

sma_package() copies studies/<study>/custom/ next to programs/ into analysis/, and the custom report (custom_report.md of the study, when Sapling wrote one; and custom/HOOKS.txt: every hook with its file, line, SHA-256 and marks, written by the package itself). run_all.R runs the generated programs, which call the hooks of the copy: the package proof (a moved copy, Rscript --vanilla) runs the customized study.

The proof on the pilot: zsh tools/custom_proof.sh#

Three cases on pilot-adam.qmd (examples/customize/):

The result on the pilot (2026-11-10)#

zsh tools/custom_proof.sh (CUSTOM PROOF PASS, about 55 minutes; its lines: examples/customize/out/custom_proof.txt):

StepResult
the compilerboth plans 0 errors; it lists the 4 hooks and ADSL.WTLOSFL (info); without the hook, without the words, without type=, a hook on a step that is not there: each an error with its fix
v1: the hooks on the dataADSL: hook_override("DURDIS") changed DURDIS for 249 of 254 subjects (completed months), and DURDSGR1 follows for 2; hook_add("adsl_wtloss") made WTLOSFL for 254 (Y for 6); ADQSADAS: hook_after("ADQSADAS.WINDOWS") took 25 item records without a value out of their windows; the 8 other data sets equal cell for cell to the run without hooks
v1 through the chain10 of 10 ADaM programs and 38 of 38 TLF programs BATCH: CLEAN; DASHBOARD PASS (40 pages, 35538 numbers equal to the ARD), APP: OK, GATE PASS; CSR Table 14-3.01 NUMBERS: MATCH; against the CSR 18 MATCH, 9 DIFFERENT (as the pilot without custom code)
v1 against the pilot without custom code38 listings: 35 the same line for line; P_DEMOG changed by its layout hook only (the same ARD, the same numbers); T14_2_01 changed where it reads DURDIS and DURDSGR1; T_AE_WTL new (the subgroup of WTLOSFL: 3, 3 and 0 patients): LISTINGS EXPLAINED
the amendment (Week 8 ends on day 70, Week 16 starts on day 71; ADSL adds AGEGR2)hook drift: 1: only hook_after("ADQSADAS.WINDOWS") (its input reads the windows); the override, the hook of WTLOSFL and the layout stay OK. Unreviewed, the ADQSADAS program runs the hook and says RESULT: CHECK ("was pinned to the generated input 20362f36; it is now eb7c6e4e"); after the programmer's review (the new pin) no drift
v2 through the chain38 of 38 TLF programs CLEAN, DASHBOARD PASS, APP: OK, GATE PASS; CSR Table 14-3.01 now NUMBERS: DIFFERENT, as the amendment changes its windows
v2 against v125 listings the same, 13 changed, each where it reads ADQSADAS or ADQSCIBC, whose records the amendment changed: LISTINGS EXPLAINED; P_DEMOG, T14_2_01 and T_AE_WTL the same as in v1: the custom code is kept
v2: the data gateDATA GATE PASS: the 10 generated ADaM programs with their hooks and the 38 table programs on other data, every number of every listing a result of its ARD
v2: the submission packageSMA|PACKAGE|programs=48|xpt=10|define=PASS|...|status=PASS, with custom/ (and custom/HOOKS.txt) and custom_report.md next to the generated programs; PACKAGE PROOF PASS (a moved copy, Rscript --vanilla run_all.R)
the custom reportevery hook OK after the review; 4 drafted by an agent and not approved: CUSTOM REPORT OPEN until a person reviews them

Without a custom folder, every file sapc writes for the examples of examples/cdiscpilot01 is the same byte for byte as before (specification, define table, programs, sap.md, HTML, review), and the proofs give their verdicts as before: SAP TO DASHBOARD PASS, CHAIN PROOF PASS, FULL SAP PROOF PASS, and in r-agentic TESTS PASS, sma_proof() 14 of 14 listings and every result the same, POC PASS (43 of 43 the same as SAS), STUDY CHECK OPEN.

Limits#

Commands#

TaskCommandVerdict
the hooks bound to the SAP (errors with the fix)node src/cli.ts check plan.qmdSAPLING CHECK PASS
the generated programs with the hooks; the driftnode src/cli.ts adam plan.qmd [--custom DIR]SAPLING ADAM PASS and hook drift: n
the pure generated programs (no hook)node src/cli.ts adam plan.qmd --no-customSAPLING ADAM PASS
the custom reportnode src/cli.ts custom-report plan.qmd [--run DIR] [--compare FILE]CUSTOM REPORT PASS / OPEN / FAIL
the three cases on the pilotzsh tools/custom_proof.shCUSTOM PROOF PASS / FAIL