Releases: NghaReformer/redcap-universal-validator
Release list
v1.6.1 — a blank field is not a failed read
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 therepeat_instancesbuckets in the result, which is whatresolveOne()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 staysok; a result
carrying other records but not the one asked for is anomalous and staysunreadable, 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`
The first cut of this release (local tag, never pushed) was reviewed and rejected: cross-form
@UVASSERTwas 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, andtests/hosting_php.phplocks 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 reportedcomplete. 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 ...
v1.5.2 — renamed to "Universal Field Validator"; tightened the module description
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. TheINSPIRE\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 injs/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)
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.checkedis always false) and
the visible clickable<input type=checkbox>carrying id
id-__chk__<field>_RC_<code>(shared name__chkn__<field>). The engine read
.checkedoff 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/@UVUNIQUEwhennever fired; a
checked-but-hidden@UVCHOICEScode was never detected as stale. - The fix. A single
QRID_readCheckbox(field, code)now resolves the state
across renderings: a__chk__…_RC_codethat IS a checkbox → its.checked
(classic REDCap); one that ishidden→value === code(17.x mirror); else
the visibleid-__chk__…_RC_codecheckbox → its.checked.readRefroutes
every checkbox reference through it,requestFieldalso binds change/click on
the visible__chkn__<field>(the hidden mirror fires no events), and the
@UVCHOICESrenderer 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_codeas 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.cjsgained 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
owndocument.bodyMutationObserver; 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
@UVCHOICESmessage 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)
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) orhide(blacklist) per
tag, plus optionalwhen,message,blockSave. Repeated tags with
differentwhenconditions branch through the existingBranching
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_calculationsat 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, andblockSave(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 ashowwhitelist's complement
without DOM enumeration — checkbox options are only findable by exact
__chk__<field>_RC_<code>name.choicesAllparticipates in the
groupMulticanonical 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::checkRefsis unaffected — it only consults checkbox entries). - Client. New
QRIDChoiceFilterInitfactory (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 ignoreshidden/display:noneon options; radio
and checkbox options hide their wrapper element. Live re-evaluation rides
the shared when-registry. - Audit + scan.
ruleFindingsgains achoicesblock: a saved value
(or checked checkbox code — the one mode that judges checkbox arrays) that
the active filter hides logstype: 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.phpdrives every fixture case through the real audit;
the DOM test through the real factory).tests/hook_php.php210→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
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-checkis declared inno-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 === falseand skipped every guard added in 1.4.1: the
surveysopt-in requirement, the Identifier refusal, and the rate limit. The
endpoint then answeredused: true/falsefor any field carrying a live
unique rule — including a field flaggedIdentifier?, 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.phpnow 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.php205→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)
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(), andmethod_exists()returns FALSE for
a magic-proxied method — so the entire block was skipped, with no exception
to notice. Now guarded withis_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.phpnow carries a
ProxyJsmoModulethat 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-transportwith 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.php197→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
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, sosurveys:trueon a field flaggedIdentifier?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 nullgetProjectId(). - Also: the Control Center description still described only
@UVALIDATEand
"Text/Notes fields" — factually wrong since 1.0.0. It now covers the four
composable rule kinds and the Validation scan. tests/hook_php.php187→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)
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 viaredcap_module_link_check_displayand 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.
auditRulewas refactored into a thin
logging wrapper over the newruleFindings()— 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.php175→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`)
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.
@UVUNIQUEtag /uniquemode. Bare (project-wide),=project|dag|event
scope shorthand, or JSON{with, scope, when, message, blockSave, surveys}.
withmakes 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 logstype: 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.php151→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.php121→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