Decision artifact2026-08-23awaiting your answerfour questions

The widget that has to carry two tables

Eleven of the thirteen charts in the safety chart library can be called from R. The two that cannot are the participant profile and the Kaplan–Meier survival chart, and the reason recorded against both is that they need two tables of data while the R binding only accepts one. That reason is wrong about one of them and understates the problem with the other. The participant profile needs one table — and it is already on screen inside eight of the eleven widgets that ship today, one click away, which nobody has written down anywhere. The survival chart genuinely needs two, and under today's binding it does not refuse them: it builds without a word of complaint and then draws an error message in the browser.

Charts in the library, and callable from R: 13 and 11 Shipping widgets that already open the participant profile: 8 of 11 Column checks today's contract runs on a two-table chart: 0 Working widgets the recommended change touches: 0 of 11
What answering this releases

The next release of the safety R package, which is the item you put at the head of the ranked ten on 21 August as the smallest piece of clinical work that still runs the whole lane. Both charts are already inside the JavaScript bundle the package ships — nothing upstream is waiting, and no re-vendoring is needed. What is waiting is the shape of the R function, and that is what these four questions decide.

THE SITUATION

Three sentences

Every chart in the library is reached from R by one function that hands the chart a single table of results plus a list of settings, and two charts do not fit that shape — the survival chart, which composes its endpoint from a table of events and a table of the population at risk, and the participant profile, which draws one participant at a time in response to a click somewhere else on the page. Both have sat as recorded deferrals since the library's last release, each citing the requirement this page answers, and neither can be built until somebody says what the R function should look like. This page builds all four candidate shapes, runs them, and reports what each one actually did — including the two failures that happen quietly, which are the reason this is a decision rather than a mechanical wrap.

WHAT WE RAN, AND WHAT BROKE

Four things executed rather than reasoned about

Everything below was run in this session against the safety R package's integration branch, with the chart library bundle it actually ships. Nothing here is read off a specification.

One — the survival chart under today's contract builds cleanly and draws an error

We wrote the obvious binding: hand the survival chart the events table as the one table the contract allows. R accepted it. It accepted it again when we replaced every column name in the settings with a name that does not exist in the data. The same nonsense handed to any of the eleven working charts is refused immediately.

Survival chart, events table only ........... no error, widget built Survival chart, id column set to NOT_A_COLUMN no error, widget built Histogram, measure column set to the same ERROR: Column 'NOT_A_COLUMN' (setting 'measure_col') not found in dfResults

The cause is not laxness. The shared check reads the list of required columns from one specific place in each chart's data contract — the part that describes a chart with a single table. A two-table contract describes its required columns per table instead, so the check looks in a place that is empty, finds nothing to check, and reports success.

Chart's data contractTables it declaresColumn checks the R side performs
Histogramone2
Kidney explorerone3
Participant profileone4
Survival charttwo — events and population0

Loaded into a browser, the widget that R had just declared healthy rendered a single line of red text: Required dataset(s) missing: events, population — pass { events, population } arrays of records. The chart library caught what the R package did not.

Why this is the shape of the decision

The recorded reason says the contract cannot carry two tables. It is worse than that: it carries them without noticing, and the first thing that notices is the page in front of a reviewer. Whatever shape is chosen has to make the R side able to fail, not merely able to pass.

Two — a second table put where the profile already puts its own arrives unusable, and nothing says so

The participant profile already reads a second domain: it draws a participant's adverse events beneath their laboratory results, and the chart library takes those records inside the settings object rather than as a second table. That looked like a free answer — carry the second table in the settings and change no signatures at all. It is not.

R serialises a table of data column by column. The one table the binding names is converted back into records in the browser by the binding itself; a table hidden inside the settings is not, so it arrives as an object of columns where the chart expects a list of rows. The chart tests for a list of rows, finds an object, and skips the whole domain without an error, a warning, or a mark on the page.

What the browser receives inside the settings: {"data":{"USUBJID":["01-701-1015","01-701-1015", ...], "AEDECOD":["APPLICATION SITE ERYTHEMA", ...], ...}} What the chart tests for: Array.isArray(settings.ae.data) -> false What the chart then does: nothing, silently

This is the same class of defect as the first one, arrived at from the other direction, and it is why the settings route is not a shortcut.

Three — the participant profile alone on a page shows nothing at all

We built the profile widget properly: correct contract, correct column checks, real laboratory data, no errors anywhere. Loaded in a browser it rendered an empty control panel and one line of text — Waiting for selection — listening on document.

The profile is a drill-down. It waits for another chart on the page to announce which participants a reviewer picked, and in a standalone R report there is no other chart. This, and not the number of tables, is the real reason the profile has no binding. Its data contract declares one table, and always has.

Four — the profile is already on screen in eight of the eleven widgets, and nothing says so

Looking for where the profile does get its selection, we found that eight of the eleven widgets the package ships today already carry it, mounted as a panel beside the chart, switched on by default. Opening the liver-safety widget from R with its own documented example and clicking a participant produces the full profile — header, demographics, the R ratio, the standardised laboratory trajectory, the measure table with sparklines — with no new code and no change to anything.

The liver-safety widget called from R with its own documented example, after clicking one participant. The panel on the right is the participant profile, mounted by the chart library and switched on by default. No new code, no setting, no binding — it ships today.

None of this is written down. The setting that controls it is not in any of the vendored data contracts, so it is undocumented, on by default, and invisible to the checks. The measurement, taken from the bundle the package actually ships:

WidgetOpens the participant profile on a clickWhich domain the profile then shows
Liver-safety explorer, histogram, shift plot, delta-delta, outlier explorer, QT explorerYes — 6 widgetsLaboratory results only
Adverse-event explorer, adverse-event timelinesYes — 2 widgetsAdverse events only
Results over time, liver waterfall, kidney explorerNo — 3 widgets

Which sharpens the profile question rather than answering it. A reviewer inside the liver widget sees a participant's laboratory story; one inside the adverse-event widget sees their event story; nobody sees both, because a host chart only feeds the profile the domain it holds itself. Both domains together is exactly what a standalone widget would be for.

THE FOUR SHAPES, MEASURED

What each way of carrying two tables actually costs

ShapeHow the second table gets thereWorking files it touchesWhat it costs
Named tables — recommendedThe function takes the tables it needs by name, and the payload carries them under one labelled slot the binding converts0 of the 22One shared helper gains a second entry point. The eleven keep the argument they have today, unchanged and unread.
One stacked tableThe caller stacks both domains into one table with a column saying which is which; the binding splits it again0 of the 22Measured on the real data: a quarter of the stacked table is padding that exists only to give the other domain its columns, and two columns appear in both domains meaning different things. The per-table column check has nothing to check against.
Inside the settingsThe second table travels as a settings value, the way the profile's adverse events do now0 of the 22Fails silently today, as measured above. Data hides in the settings, so the contract's own split between data and settings stops being true and there is nowhere natural to validate the second table.
One argument for everythingEvery widget takes a single labelled collection of tables, one-table charts included22 of the 22Every R function and every browser binding for eleven working charts is rewritten, plus their tests and any caller outside the package. The gain over named tables is tidiness.
The stacked table, on this study's real data: two named tables ..... 1,122 event rows x 10 columns + 254 population rows x 4 one stacked table .... 1,376 rows x 13 columns, 26% of cells pure padding payload ............................ 1.22x larger columns meaning two different things ......... USUBJID, ARM
THE RECOMMENDED SHAPE, BUILT AND RUN

Not a proposal — it renders

We implemented the named-tables shape end to end and ran it. The survival chart draws from R: 254 participants across three treatment arms, 217 events and 37 censored, confidence bands, censor marks, the at-risk table, and the filters that compose the endpoint live.

The survival chart drawn from R under the recommended shape, from an events table and a population table passed as named arguments. Two hundred and fifty-four participants, three arms, two hundred and seventeen events and thirty-seven censored, with the at-risk and cumulative-event strip beneath the axis.

Every failure the naive shape swallowed is now refused before a browser is involved:

events table with a bad day column .... ERROR: Column 'ALSO_NOT' (setting 'event_day_col') not found in events data population table omitted ............... ERROR: argument "dfPopulation" is missing population table without its day column ERROR: Column 'EOSDY' (setting 'fu_day_col') not found in population data

And the eleven working widgets do not notice the change:

Test suiteAssertions passingFailingSkipped
Integration branch, untouched2,941013
Same branch with both new widgets and the contract change2,941613

The six are all one thing: four roster lists that name the eleven widgets by hand and now name thirteen — the export list, the deferral list, the gallery, and the example pages. Not one assertion belonging to an existing chart changed from passing to failing. The pipeline half works too: a two-table report workflow, declaring both domains the way every risk-indicator workflow in the ecosystem already declares two, ran start to finish and wrote its HTML report.

The profile, given the participants to show and both of its domains, draws both — laboratory trajectory, adverse-event summary, adverse-event timeline on the same study-day axis, and a stepper through the selected cohort.

The standalone participant profile from R, given both domains and a two-participant cohort: laboratory trajectory above, adverse-event summary and timeline below on the same study-day axis, and a stepper through the cohort. No host chart is present — the participants to show came in as an argument.
THE QUESTIONS

Four, and only the first one blocks both charts

W1

When a chart needs more than one table, how does it ask for them?

Every widget here takes one table today. The survival chart needs two, and something in the library will need two again — the profile's second domain is already a second table wearing a disguise. This is the one answer both charts wait on.

The alternatives, with what each forecloses:

  • Named tables — the function names the tables it needs, and one shared helper reads the chart's own contract to learn which tables and which columns are required. Forecloses nothing; a one-table chart is the same call it is today.
  • One stacked table — keeps every signature identical at the price of a quarter of the payload being padding, two columns meaning different things in different rows, and no per-table validation. Forecloses honest column checking.
  • Inside the settings — no signature changes at all, and the measurement above shows it losing the data without saying so. Forecloses the contract's own distinction between data and settings.
  • One argument for everything — the tidiest end state, reached by rewriting twenty-two files that work.
Recommended: named tables. It is the shape the risk-indicator package next door already uses — four of its six widgets take two or three tables as named arguments, and this package was built to mirror it. It touches none of the eleven, and the answer to "what does this chart require" moves into the chart's own published contract instead of being written twice.

One consequence to accept with it: ask the chart library to declare the profile's adverse-event table as a real second table in its data contract, rather than leaving it inside the settings. That is one change upstream and one re-vendoring, and without it the second table stays the one thing the R side cannot check.

W2

What is the participant profile, in R?

It is a drill-down, and eight widgets already open it on a click. The question is whether it is also a thing on its own — a page you can produce for a named participant or a named cohort, save, and send to someone.

  • A standalone widget that takes the participants to show — built and shown above. It is the only surface where both domains appear together, because a host chart can only feed the profile the domain it holds itself.
  • No standalone widget — record that the panel inside eight widgets is the profile's R surface, and change the every-chart-gets-a-widget rule to say so. Cheapest, and it leaves the case-review page unbuildable.
  • A compound widget — one function that renders a host chart with the profile beside it. More than either of the above, and the eight widgets already do it.
Recommended: the standalone widget, taking the participants to show as an argument, plus a documented note that eight of the eleven already open the profile beside them. The first gives you a participant page; the second is a capability that exists, ships today, and is written down nowhere.
W3

What does the survival chart's population table look like, and where does it come from?

This package holds two vocabularies. The metric steps speak the risk-monitoring pipeline's names — lower-case, dates rather than study days. The widgets speak the analysis-dataset names a statistical programmer would recognise. The population table has to pick one.

  • Analysis-dataset shaped — one row per participant with the participant id, treatment arm, last day of follow-up and an end-of-study status. This already exists: the chart library's own demo ships exactly this table, 254 rows and four columns, and it can be vendored the way the other four example datasets were.
  • Pipeline shaped — take the mapped subject domain, which already carries days on study and would serve as the follow-up end. But the mapped adverse-event domain carries onset dates rather than study days, so this route needs a derivation step joining the two before the chart can be drawn.
Recommended: analysis-dataset shaped, matching the other eleven. The pipeline route is worth having later as a derivation step that produces the same two tables — but making the first two-table widget also the first to cross vocabularies would test two things at once and blame the wrong one when it fails.
W4

Does a widget have to be seen drawing before it counts as delivered?

The broken widget from the first failure above passes every check this package has. The cross-repository guard confirms a binding file exists for each chart. The gallery test confirms a screenshot file exists and is larger than ten kilobytes — and those screenshots are copied from the chart library, so they show the chart working there, not here. The example-page test confirms a page mentions the widget's name. The workflow tests confirm the bundle and the data reached the HTML file. Nothing anywhere loads that file in a browser and checks that a chart appeared.

That gap is invisible while every widget takes one table and copies a binding that works. It stops being invisible the moment two widgets ship on a payload shape nothing has rendered.

Recommended: yes — one rendering check per widget, loading the built report in a headless browser and asserting the chart drew and the console is clean. Roughly a day of work against a guard that would have caught the exact defect this page opens with. If you would rather not spend it now, the honest alternative is to say the four-pillar rule means the artefacts exist, not that the widget works.
WHAT UNBLOCKS

On each answer

If all four go the recommended way

Two widgets, one additive change to a shared helper, one new example dataset, no change to any of the eleven that work, and a rendering check that fails on the failure this page opens with. Roughly thirty files, of which twenty are new.

WHAT WAS ON FILE THAT IS NOT TRUE

Five, and two of them changed the recommendation

One claim we expected to find wrong and did not: the shared binding helper genuinely does validate every one-table chart properly, and the eleven are in good order. The defect is confined to the shape nothing has used yet.

One trap worth recording for whoever builds this

Twelve of the thirteen charts are created with the same two arguments — the element and the settings. The participant profile takes three, with the data in the middle. A binding copied from any of the others hands the settings in where the data belongs, and what you get is a chart complaining that four columns are missing from data it never received. That cost this session twenty minutes.

Measured for this page, not relayed. Everything above was produced on 23 August 2026 against the safety R package's integration branch at commit 277fae8, in a scratch worktree, with R 4.3.3 and gsm.core 1.2.0 — the version installed on this machine. The chart library bundle is the one the package vendors, version 1.7.0, read from the package's own files rather than from the library's source. Thirteen tests skip on this machine because the census figures were qualified against gsm.core 1.3.1 and this machine has 1.2.0; none of the thirteen touches a widget, and the skip count is identical before and after the change, so no figure on this page depends on the difference.

Sources. The safety R package on its integration branch — the shared binding helper, all eleven widget bindings, the vendored data contracts, the report workflows, the test suite and the cross-repository parity guard. The chart library at its current release — the two unwrapped charts' source and data contracts, and the shipped bundle, which was read directly to establish which widgets carry the profile panel. The risk-indicator package next door, for the multiple-table precedent. The standard mapping specifications for the subject and adverse-event domains, read from the installed package.

What was not done. Nothing was committed to the safety R package, no branch was pushed, and no issue was filed or edited. The probe worktree exists only on this machine and carries no work anyone should merge — the implementation is the answer to W1, not a thing to adopt sight unseen. The screenshots are of that probe, running locally.