Skip to content

Dynamic and linked fields: skip logic, dependent options, derived values (ADR-0014) - #94

Merged
tgerke merged 6 commits into
mainfrom
phase-9/dynamic-fields
Jul 18, 2026
Merged

Dynamic and linked fields: skip logic, dependent options, derived values (ADR-0014)#94
tgerke merged 6 commits into
mainfrom
phase-9/dynamic-fields

Conversation

@tgerke

@tgerke tgerke commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Makes the runtime honor the dynamic-form constructs the ODM model has carried since ADR-0003, and adds one vendor extension. Design recorded in ADR-0014; six commits, one per implementation phase.

What this adds

  • Skip logic. CollectionExceptionConditionOID on ItemRef and (newly modeled, standard ODM) ItemGroupRef is enforced at runtime: skipped fields hide during entry, group skips cascade to nested sections, conditions evaluate per occurrence in repeating groups, and every intake path (capture route, RTSM, lab import) rejects writes into skipped fields.
  • Dependent options. New edc:CollectionExceptionConditionOID vendor extension on CodeListItem, same excluded-when-true polarity as ODM's construct. Plain-ODM consumers ignore the attribute and see the full option list (round-trip and strip tests cover this).
  • Derived values. MethodDef jsonata expressions compute item values server-side after every accepted write, stored through the audit path as item_value.derived with a new derivation origin. The client shows a live read-only preview it never submits. Derivation cycles fail build validation; the runtime drops them defensively.

One shared pure function (evaluateFormState in packages/rules) runs identically in the browser and on the server, extending the ADR-0007 dual-evaluation pattern. Evaluation order is fixed: derivations, then skip conditions, then skipped values nulled in the evaluation context only, then option exclusions, then edit checks — so derivations read raw values, skips read derived values, and skip/derive circularity can't occur.

Decisions worth reviewing

  • Retain-and-flag (D1). A saved value whose field becomes skipped is never deleted. It renders read-only with a "not collected" badge and a Clear affordance, and a system query (SKIP.<condition>.<item>) stays open until the site clears it (audited, reason required) or the controlling answer changes back. Clearing to null is the one write still allowed into a skipped field.
  • Unevaluable conditions/methods import with warnings, not errors — CDISC example files carrying XPath expressions still import; the construct just stays inert.
  • StudyEventDef-level collection exceptions are not honored — that's visit scheduling, out of scope for form state (noted in the ADR).
  • Skip conditions evaluate in a single pass against the post-derivation context; no cascading re-evaluation. When occurrence-scoped group-skip propagation is ambiguous, the field stays collected.
  • E6-08 traceability row extended with the residual-query mechanism and dynamic-fields.test.ts evidence; system-behavior text only, no new regulatory citations.

Docs and demo

Guide pages (study-builds, data-capture, site-forms), tour, README, CHANGELOG, and a new dynamic-fields.png screenshot (also added to the scripted refresh so it can't go stale). The demo study now exercises all three constructs: pregnancy test skipped for male subjects, a conditional "Not performed" option, and computed BMI.

Verification

446 tests pass (106 odm, 26 rules, 254 api including 8 new integration tests). Browser-verified end to end: skip/unskip live, option narrowing, residual query open → audited clear → auto-close, BMI preview plus server recompute with item_value.derived audit rows. Quarto site renders clean.

Known follow-ups (out of scope)

  • A retained value in a skipped code-list field displays its coded value ("1"), not the decode — visible in the interim screenshot; the scripted refresh regenerates it after merge.
  • No authoring UI for ConditionDefs/MethodDefs yet; constructs arrive via ODM import.

tgerke added 6 commits July 18, 2026 10:07
ItemGroupRef gains the standard ODM CollectionExceptionConditionOID;
CodeListItem gains the edc:CollectionExceptionConditionOID vendor
extension (option excluded when the condition is true, matching ODM's
excluded-when-true polarity). Both round-trip through XML/JSON and the
1.3 upconvert.

Validation now resolves the new refs, warns when a referenced condition
or method lacks an evaluable jsonata expression (CDISC example files
with XPath expressions must still import), errors on circular
derivation chains, warns on mandatory derived items, and extends the
blinded-reference warnings to derivations and collection exceptions.
…ules

evaluateFormState() runs the full dynamic pipeline over a form's rows,
occurrence-aware like runChecksOverRows (which now shares its row
bucketing): derivations in dependency order, then collection exceptions
(item- and group-level, group skips cascading to descendants), then
skipped values nulled in the evaluation context only, then option
exclusions, then edit checks. Derivations see raw values; skip
conditions see derived values — the one-way order rules out
skip/derive circularity.

Stored values persisting in skipped fields surface as residual findings
under synthetic SKIP.<condition>.<item> check OIDs; skipped derived
fields converge to null through the derivation write path instead.
Cyclic derivations are dropped defensively (publish-time validation
already errors on them).

compileEditChecks now also excludes ConditionDefs referenced as
group-level or option-level collection exceptions — previously a
group-level skip condition would wrongly fire as an edit check.
New services/form-state.ts is the single gate for all intake: writes to
derived items, to fields skipped under current responses (clearing to
null stays allowed — that is how a site resolves a residual), and to
currently excluded code list options are rejected as invalid. The route
write, RTSM intake, and lab import all consult it; lab imports report
rejections per row under a new skipped_not_collected outcome.

After every accepted write, runPostWritePipeline recomputes derivations
from stored values — appended via the audit path with the new
"derivation" origin and an item_value.derived action so system-computed
values stay permanently distinguishable — and then reconciles queries.
evaluateFormChecks is now skip-aware: checks run with not-collected
fields nulled, and a stored value persisting in a skipped field raises
its own system query (SKIP.<condition>.<item>), auto-closed when the
value is cleared or the controlling answer changes. Amendment migration
runs the same pipeline so build changes recompute derivations.
FormEntryPage now runs evaluateFormState client-side on every edit —
the same pipeline the server enforces — and renders from its result:
skipped fields disappear (or, when they hold a saved value, render
read-only with a not-collected badge and an explicit audited Clear),
derived fields render read-only with a computed badge and a live local
preview that the server's authoritative recompute reconciles on
refetch, and excluded code list options drop out of selects (a saved
but now-excluded value stays visible and flagged). Sections whose every
field is skipped and empty hide entirely. Unsaved edits in a field that
becomes skipped are dropped; saved values are never touched.

Open SKIP.* queries render readable text via skipResidualMessages, a
new rules export enumerating every residual check OID a build can
produce. resolveGroup and resolveVariantForm now carry methodOid and
collectionExceptionConditionOid on resolved refs (variants inherit them
from the canonical build ItemRef), and FormPreview shows static
computed/conditional badges from them.
ADR-0014 records the design: the shared evaluateFormState pipeline and
its fixed evaluation order, ODM CollectionExceptionCondition honored at
item and group level, the edc:CollectionExceptionConditionOID CodeListItem
extension for dependent options, retain-and-flag semantics for values in
skipped fields, server-written derivations, backtick-scan dependency
extraction with publish-time cycle errors, and the blinding warnings.

The demo study now exercises all three constructs: a pregnancy-test item
skipped for male subjects, a conditional "Not performed" option, and a
BMI item derived from height and weight.
The guide now covers ADR-0014 end to end: study-builds.qmd documents the
three build constructs (skip logic, dependent options via the edc:
CodeListItem extension, derived values) and how they differ from edit
checks; data-capture.qmd documents the entry-side behavior (fields
hiding, retained values with not-collected badges and residual queries,
narrowed option lists, read-only computed fields) with a new
dynamic-fields.png screenshot; the tour points at the demo study's
pregnancy-skip and computed BMI; site-forms.qmd notes that skip logic
and derivations follow items into variants.

scripts/screenshots.mjs gains the dynamic-fields shot (staged on
DEMO-002 demographics, idempotent) so the scripted refresh reproduces
it instead of the hand-captured interim image going silently stale.
README's capture bullet and the E6-08 traceability row (evidence:
dynamic-fields.test.ts) are extended to match.
@tgerke
tgerke merged commit c910b71 into main Jul 18, 2026
1 check passed
@tgerke
tgerke deleted the phase-9/dynamic-fields branch July 18, 2026 18:03
tgerke added a commit that referenced this pull request Jul 23, 2026
Dynamic and linked fields: skip logic, dependent options, derived values (ADR-0014)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant