Skip to content

Releases: NghaReformer/redcap-universal-validator

v1.6.1 — a blank field is not a failed read

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

Found on a live REDCap 17.0.6 project while testing the 1.6.0 deployment, not by a test.

A same-form rule — both operands on the page being rendered, blockSave:"hard" — shipped as
["const", false] with deferred: true and the reason "reading its saved value failed".
Nothing had failed. Both fields were simply blank. Entering 5 against a minimum of 10
produced no verdict, no outline, and saved cleanly. Saving once so the fields held values made the
same page validate and block correctly, which is what identified the trigger.

REDCap omits a blank field from getData output, so a record whose every REQUESTED field is blank
comes back with no node at all — and readValues() read that absence as a failed read. This is
the same principle the per-field default already rests on ("absence is not, by itself, unresolvable"),
applied one level up: it was right for fields and wrong for the record node.

Two changes, because the fix must not reopen H-04 — a read that genuinely fails still must not be
judged as blank:

  • The record id field is requested alongside the caller's fields. It is stored for every existing
    record and is never blank, so a returned node becomes a positive fact rather than an inference. It
    also keeps the repeat_instances buckets in the result, which is what resolveOne() needs to tell
    a genuine blank from a value on another repeating instrument — without them, a blank cross-repeat
    reference resolved as a plain blank.
  • A record still absent is then read by the shape of the result: an empty result means REDCap
    holds nothing for this record, so every field is blank and every state stays ok; a result
    carrying other records but not the one asked for is anomalous and stays unreadable, as does a
    non-array result.

Scope of the defect: an existing record where every field referenced by that page's rules was
blank — typically the first pass at a form on a record created elsewhere. New records were never
affected (REDCap passes no record, so no read happens), nor were pages whose rules reference a mix of
blank and filled fields. The post-save audit was unaffected throughout, because it runs after the
write when the values exist; it logged the violation the browser had failed to block.

tests/hosting_php.php gains the H-06 section: 10 checks, 6 of which fail on 1.6.0, including both
contrast cases that must keep deferring.


Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.6.0 — cross-form `@UVASSERT`

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

The first cut of this release (local tag, never pushed) was reviewed and rejected: cross-form
@UVASSERT was correct only inside a narrow same-event, non-repeating envelope, and outside it
produced false passes, false failures and false audit entries. Nine findings were filed; all
nine were independently reproduced with executable probes before anything was changed. The
sections below fold those fixes into 1.6.0 rather than shipping a broken tag and superseding it.

Two further adversarial reviews of the repaired tree followed, each rejecting it again. The
second found the paths that had not been moved onto the shared resolver; the third found that
having a shared resolver was not enough, because nothing said which CONTEXTS a rule belongs to —
see Rules are evaluated where they live below. Every finding of all three rounds is folded in
here, and tests/hosting_php.php locks the third round's scenarios (21 of its 32 checks fail on
the tree that was reviewed).

Rules are evaluated where they live

The module knew how to resolve a reference for a given context; nothing decided which contexts a
rule belongs to. Each caller therefore chose its own, and each chose wrongly in a different way:
the scan ran every rule in every context of every record, and the save audit's reverse-dependency
pass ran a dependant in every same-event context of the record it had just read. The symptoms
looked unrelated but were one defect:

  • a populated field on a repeating form reported blank, because the rule was also evaluated in
    the record's base row;
  • a field collected only in event 1 reported blank in event 2;
  • one rule reporting both "unconfigurable" and a hard violation, for one record;
  • a base-form violation logged four times — once per unrelated repeat row of an unrelated
    instrument — and attributed to instruments the rule has nothing to do with;
  • two records whose composite unique key lives on an independently repeating form reported as
    duplicates of each other, because unresolvable parts of the key were substituted with ''.

ruleHostForms() locates a rule from the data dictionary and hostContextsFor() returns the
contexts that form actually occupies in a record — answered from the same signals resolveOne()
uses, so the two cannot drift. A base form is evaluated once per event it is mapped to; a
repeating form once per instance; a repeating event once per event instance; a form not designated
for an event is not evaluated there at all. The scan, the save audit's dependency pass and the
unique aggregator all go through it. A rule whose field cannot be located on any instrument is
reported, not evaluated somewhere hopeful.

The unique aggregator also runs its when, branch selectors and composite uniqueWith fields
through the resolver: an undefined pairing is refused with a stated reason instead of keyed as ''.

A settled condition is a snapshot, not a fact

A condition whose operands are all off-page was folded to a bare ["const", …] and shipped with
its configured hard block intact. That constant is page-load truth: a stale false blocked a
valid save with no way out, and a stale true silently accepted an invalid one. The fold now names
the fields the constant was read from, which is what makes the rule advisory.

The same treatment now covers the applicability gate and branch selectors, not just the
assert. A stale when switches a rule on or off, and a stale selector decides which branch runs —
both are exactly as wrong as a stale verdict, and both previously kept the block.

A scan may certify only what it actually read

Three more ways a scan could claim completeness it had not earned:

  • the dictionary read failing while one settings rule survived — every annotation rule silently
    vanished from the list and the survivor was scanned and reported complete. Dictionary success
    is now established independently of whether any rule was found, and the scan cannot proceed
    without it (a rule cannot be located on an instrument otherwise);
  • a record returned by REDCap with no event rows at all — zero contexts is not zero violations;
  • rule discovery throwing, which escaped scanProject() entirely and produced a PHP error
    page rather than a scan result.

The scan page no longer colours an incomplete result green, offers its evidence CSV for every
executed scan rather than only when violations were found, and exports rule problems and
not-scanned reasons alongside the violations.

A rule that stops checking says so, even with no branch to show

When every branch of a rule was deferred and there was no fallback, no variant was active and the
client fell straight through to its inert path — clearing the field silently, with the reason the
server had built for exactly this case discarded. The rule-level notice is now rendered whenever
zero variants are active because the rule was deferred, across all five validator kinds. An
ordinary "no branch applies here" is unchanged.

Cross-instrument checks are ADVISORY

An off-page value is read once, when the page is built, and nothing can refresh it while the page
is open. A concurrent edit on the other form therefore makes the verdict stale — and a stale
verdict that PASSES is silent, while a stale one that FAILS was a dead end. redcap_save_record
runs after the write and can recover neither.

A snapshot no longer drives a save block, at any blockSave setting. Cross-instrument rules give
live feedback as you type, name the field they were compared against and when it was read, and the
post-save audit and Validation scan are the enforcement record. Same-instrument rules are
unchanged: both sides are live in the DOM, so nothing there is a snapshot.

One resolver, shared by the browser, the audit and the scan

The form hooks, the save audit and the scan each worked out where a referenced value lived, and
disagreed: the scan reported a hard violation for data the save path called unconfigurable, and
neither noticed a value on a different repeating instrument when that value happened to be blank.
resolveOne() is now the only place that decides, and all three call it.

Ownership comes from metadata, never from whether a value happens to be present. REDCap omits
blank fields from getData output, so "the field's key is in this repeat row" answers does it
have a value
, not does it live here — reading ownership off that made a blank field on another
repeating instrument look like a resolved blank. Ownership now comes from the dictionary plus
whether that form repeats (getRepeatingFormsEvents, then isRepeatingForm), with repeat-bucket
presence as a third signal; any one of them saying "repeats" is enough to refuse the pairing.

An unresolvable branch selector no longer elects the fallback

A false when merely leaves a plain rule inert, but for a branched rule it activates the
fallback, which then enforced — flagging the field, blocking the save, and logging a violation of a
rule the designer never meant to apply. Worse, client and server picked differently: the browser
could show "OK, save allowed" while the same save logged a violation of a different branch.
Branch selection now consults the resolution first and refuses the whole decision if any selector
is unresolved. This is shared machinery, so it covered @UVREQUIRED too — that factory had no
notion of deferral at all and now honours it.

A scan that did not finish cannot look clean

scanProject returns complete / incomplete / failed. A chunk that fails or throws, a record
that was requested but not returned, a record-list read that fails, and a dictionary failure are all
recorded instead of skipped in silence. The page shows a banner and refuses the green tick, and the
CSV carries an # INCOMPLETE SCAN header — a downloaded "0 violations" from a partial pass would
otherwise circulate as a clean result.

Ordering is defined only within a domain

Choosing the comparator per PAIR made ordering non-transitive: "2" <= "10", "10" <= "1e1" and
"2" > "1e1" were all true at once, because 1e1 fails NUM_RE and fell to byte order. Ordered
comparisons now require both operands in the same domain — both numeric or neither — and are false
otherwise, whichever way round they are asked, so no cycle can form. Equality is untouched. Blank is
exempt: it is absence, not a rival domain, so [end_date]>=[start_date] with start_date not yet
entered still passes rather than inventing a violation.

Verified independently: 4374 verdicts across 27 operand shapes, 0 PHP/JS disagreements, and
0 ordering cycles across 19,683 triples.

Resolution is now three-state, not "value or blank"

Four findings (H-01, H-04, M-01, M-03) had one root cause: readValues() could not distinguish
"resolved to blank" from "could not be resolved". Both arrived as "absent from the value
map", which Logic::operandValue() renders as '' — so the module confidently validated against
a value it had never read. It now reports one of four states per field:

state meaning
ok located; the value may legitimately be empty
missing the field's form is not designated for this event (M-01)
ambiguous the field lives in a different repeating instrument (H-01)
unreadable getData threw, returned a non-array, or the record was absent (H-04)

Anything other than ok means no answer: fold() refuses to bake it, marks the rule
deferred, and records why. The browser states no verdict and never blocks; the save audit
and the Validation scan skip the rule and emit an unconfigurable note naming the field and the
reason, instead of logging a violation for correct data on every save and ...

Read more

v1.5.2 — renamed to "Universal Field Validator"; tightened the module description

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

Presentation only — no functional change, no rule behaves differently.

  • Renamed from "Universal Regex & Check-Character Validator — IDs, codes &
    patterns" to "Universal Field Validator — check-character & regex IDs,
    cross-field rules, uniqueness & dynamic choices"
    . The old name framed the
    module as ID validation; four of its five modes (constraints, required,
    uniqueness, dynamic choices) are not about IDs. The INSPIRE\UniversalValidator
    namespace and the module directory are unchanged, so this is a display-name
    change with no deployment impact. The browser-facing strings (the
    configuration-error box title and console messages in js/engine.js), the
    README and user-guide titles, and the class docblock were updated to match;
    historical CHANGELOG entries keep the name they shipped under.
  • Description rewritten shorter (~330 → ~190 words) and made scannable: each
    of the five tags gets a one-line "what it does", and the Validation scan is
    now called out as its own capability (it was previously a clause buried in the
    first sentence) — a post-save audit plus an on-demand project scan that
    re-checks every saved record, covering values entered by API, Data Import, or
    before a rule existed, with CSV export.
  • The five action-tag helper entries (shown in the Online Designer) were already
    complete; no change there.

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.5.1 — checkbox state was unreadable on REDCap 17 (live-found, pid 149)

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

Bug fix. Anyone using a checkbox in a when/assert condition, or filtering a
checkbox with @UVCHOICES, should take this release.
Found on the first live
run of 1.5.0 on REDCap 17.0.6.

  • The defect. For each checkbox option, REDCap 17 renders TWO elements: a
    hidden input named __chk__<field>_RC_<code> (its VALUE is the code when
    checked, "" when not — type=hidden, so its .checked is always false) and
    the visible clickable <input type=checkbox> carrying id
    id-__chk__<field>_RC_<code> (shared name __chkn__<field>). The engine read
    .checked off the element it found by name — the hidden mirror — so a
    checked box read as unchecked. Consequence: every [field(code)] checkbox
    reference evaluated false regardless of the real state. A cascade gated on a
    checkbox (e.g. @UVCHOICES={"when":"[pilot(1)]='1'",…}) never activated; a
    checkbox @UVASSERT/@UVREQUIRED/@UVUNIQUE when never fired; a
    checked-but-hidden @UVCHOICES code was never detected as stale.
  • The fix. A single QRID_readCheckbox(field, code) now resolves the state
    across renderings: a __chk__…_RC_code that IS a checkbox → its .checked
    (classic REDCap); one that is hiddenvalue === code (17.x mirror); else
    the visible id-__chk__…_RC_code checkbox → its .checked. readRef routes
    every checkbox reference through it, requestField also binds change/click on
    the visible __chkn__<field> (the hidden mirror fires no events), and the
    @UVCHOICES renderer computes "is this option checked" the same robust way so
    a checked option is never hidden from under the user.
  • Why the tests missed it. The DOM stub modeled __chk__…_RC_code as a real
    checkbox with a working .checked — more forgiving than REDCap 17. The stub
    now models the 17.x two-element structure (hidden mirror + visible box in one
    choicevert row); tests/choices_dom_js.cjs gained a checkbox-ref-gated
    cascade and a checked-hidden-stale case (58→67 checks) that FAIL on the old
    code and pass on the fix (verified by reverting). Backward compatibility with
    the classic single-checkbox rendering is retained and still covered.
  • Radio, dropdown, the two-level cascade, stale-kept selections, and the
    save-block (off/confirm/hard) were all verified working live on the same run.

Known issues (not fixed here)

  • Performance on rule-heavy projects. A project injecting ~69 rules made a
    checkbox click freeze the page for tens of seconds live (each rule installs its
    own document.body MutationObserver; a click that mutates the DOM fans out to
    all of them). Pre-existing and module-wide, not specific to choices mode —
    tracked separately. Candidate fix: inject only rules whose fields are on the
    rendered instrument, and share one observer.
  • Checkbox message placement. A @UVCHOICES message on a checkbox field is
    anchored inside the first option row; a show/hide list that hides that first
    code could hide the message with it. Narrow (typical cascades keep the first
    code shown); to be re-anchored above the option rows in a later release.

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.5.0 — dynamic choice filtering: the @UVCHOICES tag (choices mode)

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

A fifth rule mode. REDCap's @HIDECHOICE hides options statically;
@UVCHOICES shows/hides individual options of a radio, dropdown or
checkbox
field while a REDCap-style condition holds — cascading
country → region → site lists in one field instead of a near-duplicate field
per country.

  • Grammar. JSON form only, exactly one of show (whitelist — the
    complement of the field's own choice list hides) or hide (blacklist) per
    tag, plus optional when, message, blockSave. Repeated tags with
    different when conditions branch through the existing Branching
    machinery (one tag per country, at most one unconditional fallback); no
    active branch means no filter. Codes are validated against the field's
    select_choices_or_calculations at rule-build time; unknown codes,
    non-choice field types, and matrix membership are per-field config errors.
  • A hidden selection is never cleared. A currently-selected choice that
    becomes hidden stays visible (dropdowns keep it in place, disabled), the
    field is flagged invalid with the message, and blockSave (off/confirm/
    hard) runs through the shared save guard. Values outside the field's choice
    list (missing-data codes) are out of scope on both runtimes.
  • Plumbing. Rules carry choicesAll (the full code list, attached from
    the data dictionary) so the client computes a show whitelist's complement
    without DOM enumeration — checkbox options are only findable by exact
    __chk__<field>_RC_<code> name. choicesAll participates in the
    groupMulti canonical key, so identically-tagged fields with different
    choice lists never merge into one rule. projectFieldChoices() now
    enumerates radio and dropdown rows too (previously checkbox-only;
    Logic::checkRefs is unaffected — it only consults checkbox entries).
  • Client. New QRIDChoiceFilterInit factory (same variant/gate/boot
    skeleton as required mode, own guard item, composes with the other modes).
    Dropdown filtering physically removes and re-inserts <option>s in
    original order — Safari ignores hidden/display:none on options; radio
    and checkbox options hide their wrapper element. Live re-evaluation rides
    the shared when-registry.
  • Audit + scan. ruleFindings gains a choices block: a saved value
    (or checked checkbox code — the one mode that judges checkbox arrays) that
    the active filter hides logs type: choices, reason: hidden-choice; the
    Validation scan reports the same verdicts unchanged.
  • Tests. tests/choices_php.php (37 checks: grammar, errors, grouping,
    branching), tests/choices_dom_js.cjs (44 checks: remove/restore order,
    stale-kept semantics, conflict, survey muting, blocking), and
    tests/choices_fixture.json — the hidden-set contract consumed by BOTH
    runtimes (hook_php.php drives every fixture case through the real audit;
    the DOM test through the real factory). tests/hook_php.php 210→246.
    Full existing suite green (no regressions).

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.4.3 — the v1.4.1 survey guards were bypassable by omitting a parameter

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

Security fix. Anyone running 1.4.1 or 1.4.2 with an @UVUNIQUE rule should
take this release.
Found by adversarial review of 1.4.1 itself: the hardening
it added did not defend the path that actually mattered.

  • The defect. unique-check is declared in no-auth-ajax-actions, so the
    endpoint is reachable with no session at all. v1.4.1 decided "is this
    caller untrusted?" from $survey_hash — a value the caller supplies. An
    unauthenticated request that simply omitted the hash got
    $isSurvey === false and skipped every guard added in 1.4.1: the
    surveys opt-in requirement, the Identifier refusal, and the rate limit. The
    endpoint then answered used: true/false for any field carrying a live
    unique rule — including a field flagged Identifier?, and including rules
    whose designer never opted surveys in. An unauthenticated, unthrottled
    existence oracle ("is this national ID enrolled?") — precisely what 1.4.1 was
    written to prevent, defeated by leaving a parameter out.
  • The fix. The guards now key on authentication ($user_id), the only
    value here that means REDCap authenticated the caller; a survey hash proves
    nothing. Any unauthenticated caller — survey page or bare HTTP — must pass the
    opt-in, the Identifier refusal and the throttle. The colliding record id still
    requires an authenticated, non-survey request (and the DAG check).
  • Why the tests missed it. They covered (survey_hash, no user) and
    (no hash, staff user) but never (no hash, no user) — the unauthenticated
    caller. tests/hook_php.php now exercises that exact shape: an anonymous
    request is refused on an Identifier field and on any rule without the opt-in,
    answers boolean-only on an opted-in non-identifying field, and the same field
    still answers a staff session in full. Verified by reverting the fix and
    watching the new checks fail.
  • tests/hook_php.php 205→210.

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.4.2 — @UVUNIQUE was inert on a real REDCap (live-found)

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

Found on the first live run of v1.4.0 (pid 149, REDCap 17.0.6): every rule kind
parsed and attached correctly, but the injected config carried no
jsmoName
on a form with three unique rules — so the browser had no AJAX
transport and the live duplicate check did nothing at all. Silently. The
post-save audit and the Validation scan still caught duplicates, so no data was
wrong; the headline as-you-type check simply never ran.

  • Root cause. The transport was guarded with
    method_exists($this, 'initializeJavascriptModuleObject'). The External
    Modules framework exposes those methods through
    AbstractExternalModule::__call(), and method_exists() returns FALSE for
    a magic-proxied method
    — so the entire block was skipped, with no exception
    to notice. Now guarded with is_callable(), which honours __call() and is
    true for a directly-declared method too. Both framework shapes work.
  • Why no test caught it. The test stub declares the methods, so
    method_exists() was true in the mock and false in production — the mock was
    more permissive than reality. tests/hook_php.php now carries a
    ProxyJsmoModule that serves both methods only through __call(),
    exactly as the real framework does, so this class of mistake cannot return.
  • The empty catch was the other bug. A missing transport was swallowed,
    which is precisely what hid the diagnosis and violates the module's own rule
    that nothing fails silently. A missing/failing JSMO now logs
    uvalidate-no-unique-transport with the reason and the consequence ("the live
    duplicate check is inert on this page; the post-save audit and the Validation
    scan still apply"). The client still fails open and never traps a save.
  • tests/hook_php.php 197→205.

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.4.1 — the survey uniqueness check is refused on Identifier fields

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

Closes the one advisory from the 15 Jul 2026 security scan (v1.4.0: 0 errors,
one warning). The scanner flagged the module's no-auth-ajax-actions and asked
us to confirm two things. The first was already true — the unique-check
payload is allow-listed (field name validated, answered only for fields
carrying a live unique rule, scope/composite/opt-in re-derived from stored
rules, payload capped). The second — "a survey-side uniqueness reply does not
expose sensitive record existence"
could not honestly be confirmed: an
"already used" answer to an unauthenticated respondent IS record-existence
disclosure. That is inherent to the feature, and opt-in + boolean-only limits
the blast radius but does nothing about a TARGETED probe ("is this national ID
enrolled?"). So the guard is no longer left to the designer's reading:

  • Refused on Identifier fields. REDCap already knows which fields identify
    a person, so surveys:true on a field flagged Identifier? is now a
    configuration ERROR in both channels, not a warning — enforced again at the
    endpoint (defence in depth), and staff-side uniqueness on those fields is
    unaffected.
  • The unauthenticated path is rate-limited (30 checks/minute/session,
    fail-open when there is no session). Honest about scope: this blunts a script
    walking an ID space; it is not a defence against a targeted probe or an
    attacker who clears cookies — the Identifier refusal is.
  • The opt-in label now says what is true: "anyone holding your survey link
    can test whether a specific value is already in this study", with the
    reasonable use (a non-identifying response token) named, instead of the old
    euphemism "record-derived information".
  • Fixed a fail-open the guard itself introduced (caught by PHP warnings, not
    by a passing test): the dialog channel resolved the identifier map inside
    settingRowToRule, which has no project id and would fall back to
    getProjectId() — null on import/API contexts (SEC-002), so the dictionary
    read would come back empty and the guard would silently pass. The map is now
    passed in from the caller's explicit pid, like $types/$choices, with a
    regression test that models a null getProjectId().
  • Also: the Control Center description still described only @UVALIDATE and
    "Text/Notes fields" — factually wrong since 1.0.0. It now covers the four
    composable rule kinds and the Validation scan.
  • tests/hook_php.php 187→197.

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.4.0 — the Validation scan (retrospective project sweep)

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

The last piece of the 1.x expansion: a project page that runs EVERY configured
rule over EVERY saved record. Live validation guards the form; the scan
reaches what it cannot — Data Import Tool and API writes (save-hook coverage
is version-dependent), and records entered before a rule existed.

  • "Validation scan" project link (pages/scan.php), visible to users
    with design rights via redcap_module_link_check_display and re-checked on
    the page. Read-only; results as an on-page table and a CSV download
    (quoted, spreadsheet-formula-defused).
  • One dispatch, two consumers. auditRule was refactored into a thin
    logging wrapper over the new ruleFindings() — pure evaluation returning
    findings — and the scan consumes the same method, so the save-hook audit
    and the scan can never disagree about what a violation is. All 175
    pre-refactor hook checks pass unchanged.
  • Scan semantics. Records are read in chunks (memory-safe on large
    projects); every record/event/instance context is evaluated, with repeat
    rows merged over their event row exactly as the audit's value reader does.
    Unique rules run as ONE aggregate pass over the scanned data (project /
    DAG / event scopes honored; a group is a violation only across two or more
    distinct records) instead of a whole-project read per record.
  • Privacy by construction. The report names record / event / instance /
    field / rule / reason — never the stored value (staff open the record under
    REDCap's own access control). A DAG-bound user scans only their own group's
    records; an unresolvable DAG scans nothing rather than everything.
  • Verification. tests/hook_php.php 175→187: all four modes found where
    seeded, DAG record-set confinement, dag-scoped unique across DAGs, repeat
    instance numbers, chunked reads, config-error exclusion, and a guard that
    no stored value appears in the report.

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install

v1.3.0 — no duplicates across records (`@UVUNIQUE`)

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52

Fourth validation mode, and the module's first server round-trip: field-level
uniqueness, which REDCap has no native equivalent for. As the value is typed,
the browser asks the server whether it is already recorded in another record
(framework AJAX — CSRF-protected, survey-aware) and shows used/free live with
the usual message/confirm/block enforcement.

  • @UVUNIQUE tag / unique mode. Bare (project-wide), =project|dag|event
    scope shorthand, or JSON {with, scope, when, message, blockSave, surveys}.
    with makes the key composite (value + those fields together unique, e.g.
    specimen ID within site); available in all three configuration channels
    (new dialog boxes: composite fields, scope, survey opt-in).
  • Privacy posture. The endpoint re-derives scope/composite from stored
    rules — nothing security-relevant is trusted from the page — and answers
    ONLY for fields carrying a live unique rule, so it cannot be used as an
    existence oracle for arbitrary fields. Staff see the colliding record id
    only inside their own DAG; surveys are an explicit per-rule opt-in
    (surveys:true) answered boolean-only, never a record id.
  • Fail-open transport. No JSMO, a network error, an error response, or an
    answer that never arrives — each leaves the field unflagged and never traps
    a save; the console explains why. A one-deep answer cache plus a pending-key
    guard means one request per candidate value (the direct listeners and the
    when-registry self-watch cannot double-fire a request), and stale responses
    are discarded by sequence.
  • The race is audited, not denied. Two near-simultaneous saves can both
    pass the live check; the post-save audit re-checks the saved value against
    every other record (same scope/composite semantics via one shared
    findCollision) and logs type: unique, reason: duplicate-value.
  • Field types. Text, Notes, dropdown, radio, yes/no, true/false, slider
    (no calc); composes with the other modes on one field.
  • Verification. New tests/unique_dom_js.cjs (32 checks: transport stub,
    payload shape, composite re-check, fail-open paths, pending/stale/cache,
    survey opt-in, when-gate, composition); tests/hook_php.php 151→175 (the
    AJAX endpoint end-to-end: collision/self-exclusion/trim, anti-oracle,
    composite, survey opt-in + boolean-only, DAG masking both directions,
    payload hygiene, event-scope read, audit backstop, JSMO injection on/off,
    dialog save-gate); tests/annotation_php.php 121→136. Full JS + PHP
    7.4/8.3 suites green.

Universal Field Validator is a REDCap external module. Rule kinds: @UVALIDATE (check-character / regex), @UVASSERT (cross-field constraints), @UVREQUIRED (conditional required), @UVUNIQUE (no duplicates across records), @UVCHOICES (dynamic choice filtering).

Docs: README · User guide · Action-tag examples · Install