open.gismo · convention over configuration · page 1 of 2

The hard case, walked all the way through

HEP-201, a Phase 2 study with a hepatic safety question. The delivery is four CDISC files from the CRO and three operational extracts from the EDC vendor, the labs come from three central laboratories, and nothing in it carries a single gsm column name. Eleven frames, from the folder arriving to the charts rendering — including the wrong turn, because the wrong turn is the point.

The delivery

● ● ● ~/deliveries/2026-08-26
$ ls -1
adsl.sas7bdat            34 cols · 1,000 rows   ADaM subject-level
adae.sas7bdat            22 cols · 3,412 rows   ADaM adverse events
adlb.sas7bdat            23 cols · 61,004 rows  ADaM labs, BDS
dv.xpt                   10 cols · 611 rows     SDTM deviations
queries_20260826.csv     10 cols · 8,740 rows   EDC vendor
audit_trail.csv          10 cols · 402,118 rows EDC vendor
site_list.csv            10 cols · 42 rows      EDC vendor

No define.xml. No CTMS extract. No ECG dataset. Labs are pooled from
three central laboratories with different result vocabularies and units.

The seven headers were authored from the CDISC ADaM IG and SDTMIG variable lists and from EDC-vendor export conventions, before the alias table existed, so the matcher gets no help from having been written against them. 105 source columns in total.

Eleven frames

the user · one command

Scaffold the project, drop the files in

● ● ● zsh
$ og init ~/hep-201
 project created at ~/hep-201
    config/       3 files
    workflows/   44 workflow YAMLs snapshotted from the installed gsm packages
    input/        empty
  Next: put your data in input/, then og scan ~/hep-201

$ cp ~/deliveries/2026-08-26/* ~/hep-201/input/
$ git -C ~/hep-201 init -q && git -C ~/hep-201 add -A && git -C ~/hep-201 commit -qm "delivery 2026-08-26"

Nothing has been renamed, converted or moved. The files sit under the names the CRO gave them, which is the only state a user can be relied on to produce. The commit is the baseline every later diff is read against — and it is the user's own repository, not the product's.

the product · writes the mapping

og scan — the generator

● ● ● zsh
$ og scan ~/hep-201
reading 7 files in input/ (headers and 5,000-row samples; 0 files read in full)

routing   by header fingerprint, not filename
  adsl.sas7bdat         Raw_SUBJ, Raw_ENROLL, Raw_STUDCOMP   3 domains, one file
  adae.sas7bdat         Raw_AE
  adlb.sas7bdat         Raw_LB
  dv.xpt                Raw_PD
  queries_…csv          Raw_QUERY
  audit_trail.csv       Raw_DATACHG, Raw_DATAENT
  site_list.csv         Raw_SITE
  unrouted              Raw_EG, Raw_IE, Raw_SDRGCOMP, Raw_STUDY  nothing delivered

columns   126 required across 14 domains
  73 settled   27 by name · 11 from safetyCharts standards · 19 from the
               CDISC index · 16 from EDC-vendor conventions
  53 open      of which 12 are named by a metric workflow

vocabularies
  Mapped_LB.lbtstnam   61 distinct values, 4 literals demanded  → config/vocab/lbtstnam.csv
  Mapped_LB.lbstresu   9 distinct values                     → config/vocab/lbstresu.csv

wrote
  workflows/1_mappings/  9 files · 73 source_col: lines · 2 join steps
  config/data-config.yaml
  config/vocab/          2 files

Next: git -C ~/hep-201 diff   then   og doctor ~/hep-201

Two things worth noticing. One file can feed three domains — adsl supplies subject, enrolment and completion — because routing is by content, not by name. And the report ends by sending the user to git diff, not to a summary: what the generator wrote is the deliverable, and the user reads it in the tool they already use to read changes.

the user · reads a diff

What the generator wrote

git diff — workflows/1_mappings/LB.yaml
 spec:
   Raw_LB:
     studyid:
       type: character
+      source_col: STUDYID          # og-scan exact-ci
     subjid:
       type: character
+      source_col: USUBJID          # og-scan safetyCharts::meta_labs/adam · alt: SUBJID
     lbtstnam:
       type: character
+      source_col: PARAM            # og-scan safetyCharts::meta_labs/adam · alt: PARAMCD
     lbstresn:
       type: numeric
+      source_col: AVAL             # og-scan safetyCharts::meta_labs/adam
     lbstnrlo:
       type: numeric
+      # source_col: ???            # og-scan UNSETTLED · candidates: ANRLO, BASE, CRIT1
     toxgrg_nsv:
       type: character
+      # source_col: ???            # og-scan UNSETTLED · candidates: ATOXGR, ATOXGRN, LBNRIND

Every generated line carries how it was matched and what it beat. Unsettled columns are written as commented-out lines with their candidates, so the file names its own open items and grep -rn 'og-scan UNSETTLED' workflows/ is the work list. Nothing else in the file moved: the generator edits YAML as text and re-parses before saving, so a study's own comments and ordering survive.

⚠ Already visible, and already wrong

Look at subjid. The generator chose USUBJID here and — as the next frame shows — chose SUBJID in SUBJ.yaml, because on adsl the exact case-insensitive name match beats the alias. Two files, two identifiers, both annotated confidently. A human reading this diff would have to hold both files in their head to catch it. This is weakness W2 on the design page, in the flesh.

the product · the whole interface

og doctor — first run, and it refuses

● ● ● zsh
$ og doctor ~/hep-201
open.gismo doctor — HEP-201
~/hep-201                                    14 domains · 126 columns

  ✖ 1 blocking   the run will not start
  ⚠ 4 costed     the run will start; here is what each one costs you
  ✔ 9 domains ready

────────────────────────────────────────────────────────────────
✖ BLOCKING · identifiers do not join

  Raw_LB.subjid and Raw_SUBJ.subjid share 0 of 500 sampled values.
  Raw_AE.subjid and Raw_SUBJ.subjid share 0 of 500 sampled values.
  Both pairs are joined by an inner join:
    workflows/1_mappings/LB.yaml:76   dplyr::inner_join by: subjid
    workflows/1_mappings/AE.yaml:71   dplyr::inner_join by: subjid

    Raw_SUBJ.subjid  ← SUBJID    "036"              1,000 distinct
    Raw_LB.subjid    ← USUBJID   "0301-S036-036"    1,000 distinct

  Every Raw_SUBJ value is a suffix of a Raw_LB value, so these are the
  same participants written two ways.

  Cost if you run anyway
    Mapped_LB and Mapped_AE become empty. 8 of 29 metric workflows then
    report zero at every site — cou0001 cou0002 cou0005 kri0001 kri0002
    kri0005 saf0001 saf0003 — and srs0001, which declares no spec,
    reweights its site risk score over the 21 that did run.
    Nothing errors. The study will look exemplary.

  Fix — adsl.sas7bdat carries USUBJID as well as SUBJID, and using it
  gives 500 of 500. One line:

    workflows/1_mappings/SUBJ.yaml:23
      subjid:
        type: character
    +   source_col: USUBJID

  If the two files genuinely disagree — a lab vendor prefixing the site —
  write the affix rule instead:  config/vocab/subjid.rules.csv
    pattern,replacement,domains
    ^[0-9]+-[A-Z0-9]+-,,Raw_LB;Raw_AE

────────────────────────────────────────────────────────────────
  4 costed findings follow. og doctor --costed to see them now,
  or fix the blocker first.

og_run() is refused while a blocking finding is open.
Override with og_run(force = TRUE); forced runs are stamped as forced.

This frame is the whole argument for the direction. The finding is not “a column is missing” — every column is present and correctly typed, and today's og_validate() would have nothing to say about it. The finding is that two files disagree about who a participant is, stated with both shapes side by side, priced in named metrics, and closed by one line at a named file and line number.

the user · one line · the product verifies

The fix is verified, not asserted

git diff — workflows/1_mappings/SUBJ.yaml
     subjid:
       type: character
-      source_col: SUBJID           # og-scan exact-ci · alt: USUBJID
+      source_col: USUBJID
● ● ● zsh
$ og doctor ~/hep-201 --only keys
  ✔ key overlap
      Raw_LB.subjid   ↔ Raw_SUBJ.subjid    500/500   was 0/500
      Raw_AE.subjid   ↔ Raw_SUBJ.subjid    500/500   was 0/500
      Raw_PD.subjid   ↔ Raw_SUBJ.subjid    500/500
      Raw_QUERY.subject_nsv ↔ Raw_SUBJ.subject_nsv   unresolved — see costed

   no blocking findings

The overlap is measured before and after, so the user sees the fix land rather than being told it should have. The generator's comment is gone from the line because the human touched it — that is how og scan knows never to overwrite it on a re-run, and it needs no lockfile to remember.

the product · costed finding 1

Three central labs, one axis

● ● ● zsh
$ og doctor ~/hep-201 --costed

⚠ COSTED · a vocabulary is not settled

  workflows/4_modules/hep_explorer.yaml pins four literal values in
  Mapped_LB.lbtstnam:  ALT  AST  TB  ALP
  Your study writes 61 distinct values in that column. 0 match as delivered.

  og scan filled 9 of 61 rows from safetyCharts::meta_hepExplorer, which
  carries per-standard defaults for exactly these four measures.

    config/vocab/lbtstnam.csv
    from,to,n,note
    "Alanine Aminotransferase (U/L)",ALT,11842,og-scan meta_hepExplorer/adam
    "Alanine Aminotransferase",ALT,4310,og-scan meta_hepExplorer/sdtm
    "SGPT",,3902,                              ← unsettled
    "ALT (SGPT)",,1204,                         ← unsettled
    "Aspartate Aminotransferase (U/L)",AST,11840,og-scan meta_hepExplorer/adam
    "SGOT",,3902,                              ← unsettled
    "Bilirubin (umol/L)",TB,7891,og-scan meta_hepExplorer/adam
    "Total Bilirubin",,2140,                    ← unsettled
    …

  52 rows have a blank to and pass through unchanged. List them:
    awk -F, '$2==""' config/vocab/lbtstnam.csv

  Cost if you leave it
    hep-explorer plots 4 of 4 measures but on 42% of the records it
    should have. The Hy's Law quadrant needs ALT and TB on the same
    participant and will drop every participant whose panel came from
    the two laboratories that write SGPT and Total Bilirubin.
    kri0005 / cou0005 are unaffected — they count the grade, not the name.

This is the seam that does not exist anywhere in gsm today. The value-level mapping currently lives as literal strings inside hep_explorer.yaml's lSettings.measure_values, which means a study either happens to write “Alanine Aminotransferase” or the chart is empty and says nothing about why. Four rows of a CSV close it, and the file is generated with the study's own values already counted so the user is ranking their own data rather than recalling it.

The 9 pre-filled rows are real: safetyCharts::meta_hepExplorer has five type == "field" rows carrying ADaM and SDTM defaults per measure. That is a value-level metadata layer safetyGraphics already has and gsm.mapping does not — see page 2.

the product · costed finding 2

One measure, two units — the thing no naming surface can repair

● ● ● zsh
⚠ COSTED · one measure arrives in two units

  After the vocabulary above, ALT arrives as:
      U/L        13,884 records    median 24     range 4 – 812
      µkat/L      2,268 records    median 0.41   range 0.07 – 13.5

  The two differ by a factor of about 60. Plotted on one axis the µkat/L
  records become 2,268 apparent normals, and every reference-range
  comparison against lbstnrhi is meaningless for them.

  This is not a naming problem and no source_col: line repairs it.

  Fix — one row, in the units vocabulary:

    config/vocab/lbstresu.csv
    from,to,factor
    U/L,U/L,1
    µkat/L,U/L,60
    …

  factor multiplies the numeric columns paired with this unit column by
  convention: lbstresn, lbstnrlo, lbstnrhi. It is applied once, when the
  file is read, before any mapping SQL sees the frame — so every metric
  and every chart downstream sees one scale.

  og doctor re-checks after the edit: a measure whose values still span
  more than one unit is reported again, so a wrong factor does not pass.
Where this direction stops, on purpose

A multiplier is arithmetic, and arithmetic in a config file is a slope this design refuses to slide down. One constant per unit is the whole extension; anything needing a formula, a lookup by age and sex, or a per-visit adjustment is out of scope and og doctor says so rather than inventing a syntax. The user writes an R script, points data-config.yaml at its output, and that script is in their repository like everything else.

the product · costed findings 3 and 4

The column that cannot exist, and the domain nobody sent

● ● ● zsh
⚠ COSTED · required, and possibly not obtainable

  Raw_LB.toxgrg_nsv   CTCAE toxicity grade
    No source variable in SDTM. None in ADaM unless somebody derived one.
    Your adlb.sas7bdat has ATOXGR — 23% populated, values "" "0" "1"
    "2" "3" "4". og scan did not use it: a toxicity grade on a lab
    record is a derivation, and whether it is this grade is your call,
    not a matcher's.

    Cost  cou0005 and kri0005 (Grade 3+ Lab Abnormality Rate) do not
          run. srs0001's site risk score then averages 11 kri inputs
          instead of 12 — its denominator changes, its scale does not.

    Fix — one of:
      workflows/1_mappings/LB.yaml:47    source_col: ATOXGR
      config/data-config.yaml          decline: Raw_LB.toxgrg_nsv: "<reason>"

  Raw_STUDY   15 columns, 0 delivered
    No CTMS extract in input/. This domain is study metadata, not
    participant data: no metric workflow reads it.

    Cost  the study masthead falls back to config/study-config.yaml.
          No metric, no KRI, no chart is affected.

    Fix  decline it, or type the four fields the masthead uses into
         config/study-config.yaml.

  Same for Raw_EG (0 delivered — no ECG dataset; costs qt-explorer and
  cou0012/kri0012) and Raw_SDRGCOMP (costs kri0006/cou0006).
config/data-config.yaml — what the user writes
decline:
  Raw_LB.toxgrg_nsv: "our ADaM derives ATOXGR from a local grading table,
                       not CTCAE v5 — not comparable across studies"
  Raw_STUDY: "no CTMS extract; masthead fields typed by hand"
  Raw_EG: "no central ECG collection on this protocol"

This is the moment the direction earns its keep. A declined column is not an absent one: the reason travels into the snapshot's provenance record, which open.gismo already renders as the provenance chip, so the study site can say “Grade 3+ Lab Abnormality Rate: declined — our ADaM derives ATOXGR from a local grading table” rather than quietly not showing a KRI. A checkbox in a wizard cannot carry that sentence. A line in the study's own config, in git, with an author and a date, can.

the product · the run is allowed

Green, and what green means

● ● ● zsh
$ og doctor ~/hep-201
open.gismo doctor — HEP-201
~/hep-201                                    14 domains · 126 columns

  ✔ no blocking findings
  ✔ 10 domains ready   Raw_AE Raw_LB Raw_PD Raw_SUBJ Raw_ENROLL
                        Raw_STUDCOMP Raw_SITE Raw_QUERY Raw_DATACHG Raw_DATAENT
  ◦ 4 declined        Raw_STUDY  Raw_EG  Raw_SDRGCOMP  Raw_LB.toxgrg_nsv
  ✔ key overlap      6 joined pairs, all ≥ 99.4%
  ✔ vocabularies     lbtstnam 61/61 · lbstresu 9/9

  This run will produce  24 of 30 metric workflows
                          srs0001 over 10 kri inputs (of 12; 2 declined)
                          7 of 9 safety charts
                          qt-explorer: not built (Raw_EG declined)

$ og run ~/hep-201
… 4 phases, 38 workflows …
 snapshot 2026-08-26 written to output/ · og view ~/hep-201
✔ Green never means “complete”

It means: nothing is silently broken, and everything absent is absent on the record with a reason. The site risk score prints its denominator, two KRIs print as declined with the user's sentence, and the qt-explorer tab is not there rather than being there and empty. Compare that with today's alternative, in the next frame.

today · what a competent user actually does

The wrong turn, which is available right now

Today there is no scan and no doctor, and og_validate() cannot see source_col at all — so it stays red no matter how correct the mapping is. The rational response is to stop using it and write a preparation script, which is exactly what a competent statistician would do:

~/hep-201/prep.R — the thing a good user writes today
subj <- haven::read_sas("adsl.sas7bdat") |>
  dplyr::rename(subjid = SUBJID, invid = SITEID, agerep = AGE) |>
  readr::write_csv("input/Raw_SUBJ.csv")

lb   <- haven::read_sas("adlb.sas7bdat") |>
  dplyr::rename(subjid = USUBJID, lbtstnam = PARAM, lbstresn = AVAL) |>
  readr::write_csv("input/Raw_LB.csv")
● ● ● zsh — open.gismo v0.2.0, unchanged
$ Rscript -e 'og_validate("~/hep-201")'
open.gismo validation — HEP-201

    Raw_AE      input/Raw_AE.csv     3,412 x 16
    Raw_LB      input/Raw_LB.csv    61,004 x 14
    Raw_SUBJ    input/Raw_SUBJ.csv   1,000 x 15
  …
  ✔ 10 ready

All input domains are ready. Run og_run("~/hep-201").

$ Rscript -e 'og_run("~/hep-201")'
 snapshot written. No error. No warning. Two minutes.
✖ And the study site is a lie

Both columns are named subjid and both are populated, so validation is genuinely, correctly green. The inner joins in LB.yaml and AE.yaml return zero rows. Enrolment, query, data-entry and deviation KRIs render normally. Adverse-event rate and lab-abnormality rate read 0.00 at all 42 sites. The site risk score averages the metrics that survived and ranks every site as low risk. A medical monitor opens this and sees a clean study.

Two rename() calls, six words apart, written by somebody doing everything right. That is the failure this direction is built around, and it is the reason the diagnostic has to be the interface rather than a report the user is free to skip.

the tally

What the user had to do, and what they never saw

The user's whole contributionwherelines
Change one identifierworkflows/1_mappings/SUBJ.yaml1
Settle four lab-measure namesconfig/vocab/lbtstnam.csv4
One unit factorconfig/vocab/lbstresu.csv1
Three declines, each with a reasonconfig/data-config.yaml3
Eight remaining unsettled columns, none read by a metricvarious8
Total17

Seventeen lines of text against a 126-column contract, in files the study's own git history already tracks, produced through five commands and one git diff. The 73 the generator settled and the 47 metric-to-column dependencies it priced were never surfaced as questions, because they were never decisions.

And the honest counterweight

Seventeen is the number when the ranking works. The same run leaves 53 columns unsettled in total; the claim that only 12 matter rests entirely on the metric dependency graph being right, and the claim that a user finds the one identifier line rests on them reading a diff carefully at the exact moment they are least motivated to. Both are on the design page as W2 and W4, and neither is solved here.