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.qmdandtlf-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 bysapling checkuntilsapling sap syncwrites 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#
| Folder | Who writes it | When it changes |
|---|---|---|
the generated folder: out/adam/ (sapc adam --out DIR): programs/<ds>.R, specs/adam/, define.*, hooks.json | sapc adam only | always 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>.R | the programmer | never 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 command | Step id | Variables it makes |
|---|---|---|
::: {.dataset name="ADSL" label="..." structure="..."}... (the records and every block copyvars) | ADSL | the copied variables |
::: {.derive variable="ADSL.TRT01PN" rule="..."}, flag, cut, pooling, baseline, changefrombase, rangeindicator | ADSL.TRT01PN (the variable) | the variable (a block cut also its codes=) |
::: {.parameters dataset="ADQSADAS"} | ADQSADAS.PARAMS | PARAMCD, PARAM, PARAMN, AVAL |
::: {.windows datasets="ADQSADAS"} | ADQSADAS.WINDOWS | AVISIT, AVISITN, AW* |
impute, addrecords, relativechange | ADQSADAS.IMPUTE1, ... (as the block id) | records |
::: {.parameter parameter="ADQSNPIX.NPTOTMN" rule="..."}, ::: {.timetoevent parameter="ADTTE.TTDE"} | ADQSNPIX.NPTOTMN, ADTTE.TTDE | records |
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
| Place | When it runs | The 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 SAP | hook_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 stay | hook_override() |
| no place | at the derive block of the SAP with custom="<id>": it makes that variable and nothing else | hook_add() |
after = "end" | late processing: after the last step, before the data set is written | hook_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.
expect = c("VAR", ...)names the variables the step may make, change or drop. A step that touches another variable stops the program with the variable's name. Withoutexpect=the effect is still measured and shown in the custom report (records in and out, variables added, removed, changed).input = "<fingerprint>", the marks# drafted by:and# approved by:, the hook drift and the custom report work as for the long form.- In SAS the twin of
custom_step("<id>", ...)is%macro custom_<id>(data=);incustom/<ds>.sas. The R file says the place; the generated SAS program calls the macro there. A custom step without its twin is NOT MADE in SAS, with the macro to write.
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")
| Hook | Runs | It may change | It must not |
|---|---|---|---|
hook_before("<step>", f) | before the step | anything (the effect is measured) | — |
hook_after("<step>", f) | after the step | anything (the effect is measured) | — |
hook_override("<VAR>", f) | after the step that makes VAR, on its result | only the variables of that step | change 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 id | change another variable or the records |
hook_filter("<name>", f) | at the end, before sma_finalize() | remove records | add 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 SAP still states the rule, in words, with its citation (a citation after it or after its section heading).
custom=<hook id>: the code ishook_add("<hook id>", f)incustom/<ds>.R. Several variables may share one hook id: one function makes them all.r={...}: a one-line R expression of the other variables; the generated program writes it asmutate(VAR = <expression>). No custom file is needed.sas={...}is its line of SAS (VAR = <expression>;in a DATA step of the SAS program); without it the SAS program does not make the variable (NOT MADE).type=is required (no rule tells the type);label=unless the standards name the variable;length=for text.- The specification and the define table mark the variable: origin
Derived, method "<the words> (custom code: custom/adsl.R hook_add("adsl_dur"))".sapc checklists it as custom.
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:
| Hook | Its 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_filter | every 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:
| Status | Meaning | What the programmer does |
|---|---|---|
OK | its pinned input = is the fingerprint now | nothing |
DRIFT | the generated input changed since the pin | read the change of the SAP, change the hook if needed, then write the new input = |
NOT PINNED | no input = | review it, then write input = |
GONE | its step or variable is no longer in the SAP | an 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:
- the hook's own effect, logged by
sma_custom_apply()at each call (output/custom/<program>.tsv: records in and out, variables added, removed, and changed with the number of changed values); - the effect on the data sets, by running both: the generated programs with the hooks and without them
(
sapc adam --no-custom), the data sets compared cell by cell (--compare).
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/):
- A, a hand adjustment that survives an amendment: a hook that changes a derivation of the pilot and an extra ADSL flag for a new subgroup table; the chain runs; then the SAP is amended (a window changes) and generated again: the hooks are kept, the drift report names the hooks whose input changed (and only those), the outputs show both the amendment and the custom code, the gates pass.
- B, logic the rule language cannot say: a variable that needs real R (
custom=), with the hook and the words. - C, a TLF hook: a reordered block and a split row of a table, every number traced to the ARD.
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):
| Step | Result |
|---|---|
| the compiler | both 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 data | ADSL: 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 chain | 10 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 code | 38 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 chain | 38 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 v1 | 25 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 gate | DATA 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 package | SMA|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 report | every 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#
- The fingerprint follows the variables a hook's code names (upper-case names of the data set). A hook that builds a
name at run time (
data[[paste0("AVAL", k)]]) is not followed there: pin it after each amendment by reading it. - The fingerprint of a layout hook is the output's SAP block and its analyses; a change of the data under the table is not a drift (the layout does not read the numbers).
- A hook is called where the generated program calls it: a hook added to
custom/aftersapc adamruns only after the nextsapc adam(a hook removed from the file stops the program with its name). - The labels a layout hook writes are words; the gates read numbers from the value columns only.
Commands#
| Task | Command | Verdict |
|---|---|---|
| the hooks bound to the SAP (errors with the fix) | node src/cli.ts check plan.qmd | SAPLING CHECK PASS |
| the generated programs with the hooks; the drift | node 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-custom | SAPLING ADAM PASS |
| the custom report | node src/cli.ts custom-report plan.qmd [--run DIR] [--compare FILE] | CUSTOM REPORT PASS / OPEN / FAIL |
| the three cases on the pilot | zsh tools/custom_proof.sh | CUSTOM PROOF PASS / FAIL |