Universal Field Validator — check-character & regex IDs, cross-field rules, uniqueness & dynamic choices (REDCap external module)
Live, as-you-type validation for any REDCap field with a structure. A mistyped participant ID costs hours of reconciliation later; this module catches it while the person who typed it is still looking at the field.
Documentation: https://nghareformer.github.io/redcap-universal-validator/ — one page per action tag.
Two validation families, one engine:
- Check-character IDs (the flagship): participant and specimen IDs minted
with ISO 7064, Damm, Verhoeff, or Luhn check characters. Recomputing the check
catches virtually every typo and mis-scan — including the ones a regex can
never see, like a
3typed as an8. - Any structured value (regex): study codes, lab numbers, device serials, legacy IDs, or anything else with a fixed shape. In stock REDCap a custom regex validation type has to be added server-wide by an administrator; here a project designer sets a pattern per rule, and typists get progressive "what's still missing" guidance instead of a bare error.
Configured entirely through REDCap's settings screen or field annotations — no code pasting, no JavaScript Injector. IDs minted by the companion QR/ID generator validate identically here, in Excel, and in the browser (same verified engine).
- Single-value fields — recomputes the check character and/or tests the
format, with a green "verified" or red "typo?" message under the field. Format
errors and check-character errors are reported separately, so the person knows
which kind of mistake they made and where. A "should end in X" hint naming
the expected check character is available per rule (
suggestFix) but OFF by default — a visible expected character can entice staff to force-fit a mistyped ID instead of re-scanning it. - Pooled fields — splits a box holding several IDs (space/comma-separated, or jammed together with no separator) into individual IDs at the boundaries where the check character verifies, then shows one chip per member with warnings for leftover junk, duplicates, and wrong pool size.
- Configurable enforcement per rule — informational (message only), advisory (warn and confirm before saving), or compulsory (block the browser form/survey save until fixed). Compulsory blocks human form saves in the browser; it cannot stop an API or data-import write (see the safety net below), and it never traps a read-only field the user cannot fix.
- Server-side safety net — a
redcap_save_recordhook re-checks the saved value on the server with the same rule semantics as the client (single and pooled fields, check character, format pattern, regex-only) and logs any invalid value to the module log, scoped to the instrument that was actually saved. It fires after the write, so treat it as detection/audit, not a hard reject; the client Compulsory block is the primary control for human form entry. Coverage caveat: whether this hook fires for Data Import Tool and API writes depends on your REDCap version and how those imports are performed — do not assume import/API writes are audited until you have verified it on your own instance (the step is indocs/TESTING.md). A rule the server cannot evaluate leaves auvalidate-unconfigurableentry and a hook failure leaves auvalidate-audit-errorentry, so neither can pass silently. Raw identifiers are not stored in the log by default — a keyed, project-scoped hash is (HMAC-SHA-256 with a module-held secret), configurable per project down to logging nothing. A keyed hash is pseudonymization, not anonymity: treat the module log as identifying data in access and retention policies. Format-pattern audits cover printable-ASCII values; other values are left to the client and the check-character math, because JavaScript and PCRE regex semantics are only proven to agree on that subset. - Save-time settings validation — an invalid rule (unknown algorithm, catastrophic or non-compiling regex, unsafe pooled lengths, unsupported field types) is rejected when the Configure dialog is saved, with a message naming the rule, instead of surfacing later on a data-entry form.
- Accessible by construction — validation messages are polite live regions
tied to their inputs with
aria-describedby/aria-invalid, save-block dialogs name fields by their visible label, and every state pairs color with a text mark. Screen-reader behavior still needs the manual pass indocs/TESTING.mdbefore a release is called accessible.
One rule = one kind of validation, applied to any number of fields. Every rule
kind — Single value / Pooled (check-character or regex), Constraint
(@UVASSERT) and Required (@UVREQUIRED) — is available in all three
channels; the dialog's "What this rule checks" selector picks the kind:
-
The settings dialog — pick fields with the field picker (click its
+to add more fields to the same rule) and choose the method and enforcement. -
Fast entry — type field names into the rule's fast-entry box, separated by commas or spaces. Unknown names show a configuration error instead of failing silently.
-
@UVALIDATEfield annotations — tag fields where you already design them: the Action Tags box in the Online Designer, or thefield_annotationcolumn of the data dictionary CSV. Tagging 50 fields is one spreadsheet column and one upload:@UVALIDATE default check (ISO 7064 Mod 37,36) @UVALIDATE=verhoeff pick the algorithm @UVALIDATE={"algorithm":"none","pattern":"FC[0-9]{4}","blockSave":"hard"} @UVALIDATE={"type":"pooled","expectedIds":3} pooled field, warn unless 3 IDsJSON keys:
type,algorithm,source,pattern,strip,keepChars,idLengths,idMinLen,idMaxLen,expectedIds,blockSave,when,suggestFix,note. A malformed tag shows a configuration error under that field — never a silent no-op. Fields with identical tags are grouped into one rule automatically, and one field may carry SEVERAL tags when each has a differentwhencondition (branched validation — see below).Algorithm shorthands. So you need not spell out the full internal name, the
algorithmvalue accepts case-insensitive shorthands (e.g.@UVALIDATE=3736= ISO 7064 Mod 37,36). They also work inside the JSON form ({"algorithm":"9710", ...}).Shorthands Resolves to 3736,37,36,37_36,mod37_36iso7064_mod37_36(default)1110,11,10,11_10,mod11_10iso7064_mod11_109710,97,10,97_10,mod97_10iso7064_mod97_10112,11,2,11_2,mod11_2iso7064_mod11_2372,37,2,37_2,mod37_2iso7064_mod37_2letters1/letters2iso7064_letters1/iso7064_letters2mod10luhngs1,gtin,ean,upcgs1_mod10(GS1/GTIN/EAN/UPC Mod-10)aba,routingaba_mod10(US ABA routing Mod-10)mrz,icaomrz_mod10(ICAO 9303 MRZ Mod-10)isbn,mod11w,weighted11weighted_mod11(ISBN-10 weighted Mod-11)regex,formatnone(format/regex only — pair with apattern)The full names still work everywhere; shorthands are resolved when the module builds its config, so the browser and the server-side audit both see the canonical name. The single source of truth is
ALGORITHM_SYNONYMSinphp/AnnotationRules.php.
Any rule, in all three configuration channels, may carry a condition; the rule validates only while the condition is true:
@UVALIDATE={"algorithm":"verhoeff","when":"[specimen_type]='2'"}
@UVALIDATE={"algorithm":"none","pattern":"FC[0-9]{4}","when":"[consent(1)]='1' and [site]<>'9'"}
The Configure dialog offers the same thing as an "Only validate when" box
on each rule (fast-entry fields share the rule's box). Fields whose tags differ
only in when become separate rules; identical tags still group.
The condition language is a REDCap-style subset — not byte-for-byte REDCap logic:
| Supported | Rejected when the rule is saved |
|---|---|
[field] and [checkbox(code)] references |
functions (datediff(...), …) |
'text' / "text" / number literals |
smart variables ([record-name], …) |
= <> != > < >= <= |
[event][field] prefixes (cross-event) |
and / or / not, parentheses |
arithmetic and piping |
Semantics worth knowing before relying on it:
-
Comparisons are numeric when both sides look numeric (
[age]>'9'with age10is true, not a lexicographic accident), and exact case-sensitive string comparison when neither side does. When the two sides are in different domains — one numeric, one not, both non-empty —=and<>still answer by string identity, but<><=>=are false whichever way round you ask: ordering across domains produced cycles ('2' <= '10','10' <= '1e1'and'2' > '1e1'were all true at once). An empty side is exempt, because empty is absence rather than a rival domain —[end]>=[start]withstartnot yet entered still passes. A missing or empty field reads as''; a checkbox reference reads'1'/'0'. -
Referenced fields on the same instrument react live: pick the right dropdown option and the gated field's verdict appears or clears immediately — including a Compulsory save block. A calc field updates without DOM events, so a calc ref refreshes at the next event on any watched field — the manual check for this is in
docs/TESTING.md. -
Fields on other instruments are resolved on the server against the record's saved values, since such a field cannot change while this page is open. What the page then receives depends on the comparison:
- Against a literal (
[baseline_eligible]='1') there is no live side, so the whole comparison is settled here and sent as atrue/false— correct as of page load, and nothing on this page can change it. - Against a field on this instrument (
[end_date]>=[start_date]) the comparison is kept live (1.6.0): the off-page value is baked in and the browser re-checks as you type. This requires you to be entitled to read that instrument — authenticated data entry, with REDCap rights to it. On a survey, or without those rights, the value is withheld and the rule is deferred (no verdict, never blocks — what that costs is spelled out under@UVASSERTbelow).
So the page carries field names, your own literals, booleans — and, only in the live case, values you already have the right to read. A survey respondent, or a user without rights to that instrument, never receives one. A brand-new record has no saved values yet, so such refs resolve as
''. - Against a literal (
-
A reference the module cannot resolve is refused, not guessed (1.6.0). Until then an unreadable reference looked exactly like a genuine blank, and the rule was checked against a
''that had never been read. Three cases are now detected positively:- A different repeating instrument. Instance 3 of form A has no defined counterpart in form B, so a reference across two independently repeating instruments is treated as a configuration problem: the rule stops checking and says why. Everything else still resolves — a repeating form reading a non-repeating one, the event's base row, two fields in the same repeating instrument, and repeating events.
- A different event. Where the module can read the project's instrument-event mapping, a reference to a field on a form that is not designated for the event being saved is refused the same way. Where that mapping cannot be established (a classic project, or a REDCap build that does not expose it), the reference still reads as empty — keep both fields in the same event.
- A read that failed. A
getDataerror or a malformed result defers the rule instead of treating the missing value as blank.
In each case the browser shows no verdict and never blocks, and the reason names the field and the fix. For
@UVASSERTthe server reports it too: auvalidate-unconfigurablemodule-log entry after a save, and a Rule problems line on the Validation scan page. -
A false condition skips the rule — it never erases the value. That is the deliberate difference from REDCap's own field branching, which erases hidden fields on save. Combine
whenwith normal field branching if you also want erasure. -
The server-side audit honors the same condition against the record's saved values, so browser and audit skip (or check) a rule consistently.
-
Caps: 500 characters, 20 field references, 10 nesting levels. References are checked against the data dictionary at save time — unknown fields, missing or wrong checkbox codes, and refs to file/descriptive fields are configuration errors, not silent surprises.
The dialect is specified normatively in php/Logic.php; the
browser twin lives in js/engine.js, and tests/when_fixture.json locks the
two together (see Verification below).
Since 0.9.0, a field may be covered by MORE THAN ONE rule, provided the
sharing is gated: every sharing rule carries a when, except at most ONE rule
without a condition, which becomes the else branch. The rule whose
condition is true validates the field; if none is true, the else branch does;
if there is no else either, the field is simply not validated at that moment.
Field annotation (two tags in one box), or two dialog rules covering the field:
@UVALIDATE={"algorithm":"verhoeff","when":"[specimen_type]='2'"}
@UVALIDATE={"algorithm":"none","pattern":"FC[0-9]{4}"} <- the "otherwise"
Rules worth knowing:
- All configuration channels mix freely — a dialog rule and an
@UVALIDATEtag may legally share a field, as long as the sharing is gated. - Rejected at save time (a configuration error, never silent): two when-less rules on one field; two rules with byte-identical conditions; a single-value rule and a pooled rule sharing a field.
- Overlapping conditions are a runtime conflict: if two conditions are
ever true at once, the field shows a "Validation conflict" notice naming
both conditions, validates nothing, and NEVER blocks the save; the server
logs the same conflict to the module log. Mutually exclusive conditions
(
='2'vs<>'2') can never conflict. blockSaveandsuggestFixare per branch — e.g. Compulsory for blood specimens, informational otherwise.- The branch semantics are specified normatively in
php/Branching.php; browser and audit implement the same table (see Verification).
Stock REDCap cannot block a bad relationship between fields at entry: branching
only hides, a range check only warns, and Data Quality runs in batch. @UVASSERT
closes that gap — the field is invalid unless a condition holds, checked live and
enforced with the same message/confirm/block modes as everything else:
@UVASSERT="[end_date]>=[start_date]"
@UVASSERT={"assert":"[dose]<=[max_dose]","message":"Dose exceeds the protocol maximum","blockSave":"hard"}
@UVASSERT={"assert":"[sex]='2'","when":"[pregnant]='1'","message":"Pregnant participants must be recorded female"}
- The condition uses the same dialect as
when([field]/[checkbox(code)]refs,= <> != > < >= <=,and/or/not, parentheses). ISO dates and numbers compare correctly; the evaluator is the one parity-locked engine, used here as the test rather than a gate. - An empty field is inert — requiring a value is
@UVREQUIRED's job, not a constraint's. Confirm-a-value ("type it twice") is just@UVASSERT="[id]=[id_confirm]". messageis your own wording, shown on failure (a generic line is used if you omit it — recommended to set one, since only you can word an arbitrary relationship).- Optional
whenenforces the constraint only while a condition is true; several@UVASSERTtags with differentwhenconditions branch (plus at most one without, as the fallback), exactly like@UVALIDATE. - Works on Text, Notes, dropdown, radio, yes/no, true/false, calc and slider
fields (check-character/regex validation stays Text/Notes). A field may carry
@UVASSERTalongside@UVALIDATEand other modes — they compose, and all must pass; each keeps an independent save-block state. - The server audit honors the constraint against saved values (logged as
type: constraint). - Across instruments (1.6.0) — live, but ADVISORY. The condition may reference a
field on another instrument in the same event. When you are entitled to read that
field — staff data entry, with REDCap rights to its instrument — its value is
resolved on the server and baked into the condition, so the check stays live
as you type. It does not block the save, at any
blockSavesetting: that value is read once, when the page opens, and nothing can refresh it while you type, so a concurrent edit on the other form would otherwise produce a wrong block, or a wrong pass, with no way to tell which. A failure names the off-page field and says when it was read, so you know to reload; the post-save audit and the Validation scan are the enforcement record. Survey respondents see the plain message, without field names. - Rules whose fields are all on THIS instrument are unaffected and still block exactly as before — both sides are live in the form, so nothing is a snapshot.
- Deferred means detection, not prevention. On a survey, or for a user
without rights to the referenced instrument, the value is never sent to the
page (SEC-005) and the rule is deferred: the browser shows no verdict and
never blocks, so the save is accepted.
redcap_save_recordfires after the write, so the audit logs the violation to the module log once it has happened, and the Validation scan can find it later. Deferring buys privacy at the cost of both live feedback and the block — someone has to read the log or run the scan. - A reference the module cannot resolve is refused (1.6.0) — a field on a
different repeating instrument, a field not collected in this event, or a read
that failed. The rule stops checking, and the reason names the field instead of
comparing against a blank that was never read; see the
whensection above for the three cases and what still resolves normally.
REDCap's own required flag is unconditional and only warns. @UVREQUIRED adds
the two things it lacks: a condition and a real block:
@UVREQUIRED always required
@UVREQUIRED="[consent]='1'" required only while consented
@UVREQUIRED={"when":"[consent]='1'","message":"Phone needed for consented participants","blockSave":"hard"}
- A blank (or whitespace-only) field shows the notice while the requirement is
in force; the requirement turns on and off live as referenced fields
change. Filling the field clears the notice — deliberately no green "OK",
because required mode never judges the value. Pair it with
@UVALIDATEor@UVASSERTfor that (modes compose, each with its own save-block state: on a blank field only@UVREQUIREDfires; on a filled-but-wrong value only the value checks fire). - Works on Text, Notes, dropdown, radio, yes/no, true/false and slider fields. Not calc — the person entering data cannot fill a calc, so requiring one would trap them (the same reasoning as the read-only exemption: a read-only field shows the notice but never blocks the save).
- Several
@UVREQUIREDtags with differentwhenconditions branch; two conditions true at once is a visible conflict that never blocks. - The server audit logs a blank-while-required save as
type: required,reason: required-blank(a blank carries nothing identifying, so this entry is safe in every privacy mode).
REDCap has no native field-level uniqueness. @UVUNIQUE checks the value
against every other record as it is typed (a CSRF-protected module AJAX
call — no page reload), with the usual message/confirm/block enforcement:
@UVUNIQUE unique across the project
@UVUNIQUE=event scope: project | dag | event
@UVUNIQUE={"with":["site"],"message":"Specimen already registered","blockSave":"hard"}
withmakes the key composite — the value plus those fields together must be unique (a specimen ID within its site). Scopes:project(default),dag(unique within each Data Access Group),event(within the same event of a longitudinal project).- Under
dag, records in no group form one group of their own. They are compared against each other, not exempted: "no DAG" is a scope like any other, and the alternative reading — that an ungrouped record has nothing to be compared with — would let the rule lapse exactly where records are hardest to attribute. Useprojectscope if you want them compared against everything. - Privacy posture. The endpoint answers only for fields carrying a unique rule (it cannot be used to probe arbitrary fields for value existence). Staff see the colliding record id only when it is inside their own DAG. Comparison is exact (trimmed) against stored values.
- Surveys: opt-in, and never on an identifier. Survey respondents are not
logged in, so an "already used" answer tells anyone holding the survey link
that a specific value is in the study — one value at a time.
{"surveys":true}is therefore off by default, answered as a bare yes/no (never a record id), rate-limited, and refused outright on any field REDCap flags as an Identifier — there it would let a stranger test whether a named person is enrolled, so the module makes that a configuration error rather than trusting a warning to be read. Reasonable use: a non-identifying response token, to stop the same person submitting twice. Leaving it off costs no data quality — survey submissions are still covered by the post-save audit and the scan. - 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 and logs a collision (
type: unique,reason: duplicate-value) — review the module log for races. Any transport failure fails open (a network error never traps a save). - Works on Text, Notes, dropdown, radio, yes/no, true/false and slider fields; composes with the other modes on the same field.
REDCap's @HIDECHOICE hides options statically; @UVCHOICES shows or
hides individual options of a radio, dropdown or checkbox field based on
the live values of other fields — a country → region → site cascade in one
field instead of one near-duplicate field per country:
@UVCHOICES={"when":"[country]='1'","show":["101","102","103"]}
@UVCHOICES={"when":"[country]='2'","show":["201","202"]}
@UVCHOICES={"when":"[legacy_entry]<>'1'","hide":["9"]}
@UVCHOICES={"when":"[pilot(1)]='1'","show":["s01","s02"],
"message":"Only pilot sites during the pilot phase.","blockSave":"hard"}
- JSON form only. Exactly one of
show(whitelist — every other code of the field hides) orhide(blacklist) per tag, plus optionalwhen,message,blockSave. Codes must exist in the field's own choice list — an unknown code is a configuration error naming the real codes. - Branches like every other mode. Repeat the tag with different
whenconditions (plus at most one without, as the fallback). Exactly one true condition filters; none (and no fallback) shows everything; more than one true is a visible conflict — the filter is not applied and the save is never blocked on a configuration problem. Conditions may reference fields on other instruments; those are resolved server-side against saved values (nothing off-page leaks into the browser). - A hidden selection is never cleared. If the stored/selected choice
becomes hidden (the user changes the country after picking a site), the
module keeps it visible — dropdowns keep the option in place but disabled —
flags the field invalid with your
message, and appliesblockSave(off/confirm/hard). Silently erasing an entered value is the one thing this mode refuses to do; fix it by picking one of the shown choices. - Out-of-list values are out of scope. A value that is not in the field's
choice list at all (e.g. a missing-data code like
-99) is never flagged. - Works on radio, dropdown and checkbox fields; not yes/no, true/false,
sql, or matrix fields (matrix rows render different markup — the tag is
refused there rather than half-working). Dropdown filtering physically
removes and re-inserts
<option>s because Safari ignores CSS hiding on options. Configure via field annotation only in this version. - Audited like everything else. A save that lands a hidden choice (API,
import, a race) is logged by the post-save audit as
type: choices,reason: hidden-choice, and the Validation scan reports it retrospectively.
Live validation guards the form; it cannot reach values that arrived through the Data Import Tool or the API (where the save-hook audit is version-dependent), or records entered before a rule existed. The Validation scan project page closes that gap: it runs every configured rule — check-character/format, constraint, required, unique, and choice filtering — over every saved record and lists each violation with a CSV export.
- Where: the "Validation scan" link on the project's left menu (visible to users with design rights; the page re-checks). Records are read in chunks, and since 1.7.0 findings are handed to the writer as they are produced rather than collected into one array first.
- It is still bounded by the project, and that is not yet fixed. Three things
still grow with the data and are held until the scan ends: the record-id list,
the unique-rule candidates, and one note per record that could not be read. The
export additionally spools the whole report to a temporary file before sending
a byte, so it needs disk for the full report and produces no output until the
scan finishes. On a project large enough for that to matter the request will
reach a proxy timeout first. Removing these is the durable-scan work, not
something this release did. The scan takes a budget — 75% of the server's execution
limit, 70% of its memory limit — and stops rather than being killed: both
limits are uncatchable fatals that would otherwise render a blank page with
nothing recording that the project was not examined. A stopped scan reports
incomplete, counts the records it did not reach, and says plainly that duplicates are under-reported, because uniqueness is the one check that needs the whole project.
The project-wide scan is temporarily withdrawn (1.8.9). The page explains this and starts nothing; the CSV route answers 503. It is being rebuilt as a resumable background job that can cover a project of any size and record exactly what it covered, per
reports/scan-rebuild-plan-2026-08-17.md. The description below is what it does when it returns. Live form validation, the save-time audit and the uniqueness check are unaffected and continue to run.
- What the report says. Record, Data Access Group, event, instrument,
instance, field and field label; the offending value; the rule number, the
rule's own name, and what is wrong in plain English rather than a reason
code. Columns that do not apply to a project's shape are absent rather than
empty: a classic project has no Event column, a project without groups has no
DAG column. Dropping the Event column is a claim that every finding is in the
same event, so it is kept whenever the event map cannot be read — showing raw
event ids, and saying why. The wording comes from
php/messages/catalog.json, which the browser and the server both read, so the sentence a respondent saw and the sentence in the report cannot drift apart. - Values are a policy choice. Validation scan report in the module's project settings: show locations only (the default), redact the fields REDCap marks as an Identifier, or show every value. Whatever the project chooses is capped by the reader's own export rights: raw values reach a user with Full Data Set export rights, and a user with no export rights never sees one. Redaction fails closed — an unreadable data dictionary withholds every value, because a dictionary that cannot be read cannot clear a field, and an unreadable setting falls back to locations rather than to disclosure. A report is readable by anyone with design rights, which is a wider audience than REDCap's record-level access control, so choose accordingly. A finding with no value to show, such as a blank required field, renders an empty cell — it never claims something was withheld.
- Scope must be knowable. A user confined to a Data Access Group whose
group name cannot be resolved is refused, not silently scoped to
nothing: a scan of zero records is not a clean project. Inside a group-scoped
scan, a record whose group cannot be established is left out rather than
admitted, the count of such records is stated, and the scan reports
incomplete— a group that cannot be read is not this group. - Nothing is skipped in silence. A rule the scan cannot evaluate is listed as a rule problem with the reason: a broken configuration, a field that cannot be located on any instrument, a project-scope uniqueness rule under a group-scoped scan, an instrument designated to no event (which collects nothing, so the rules on it can never fire), or an instrument you do not have access to.
- Design rights are not instrument rights. The scan reads through REDCap's export API with no user attached, so REDCap's own per-instrument access control never runs on it. A rule that reads an instrument you cannot open is therefore skipped before it is evaluated, and named as a rule problem — no finding, no count and no label from that instrument reaches the report, which filtering the rows afterwards could not promise. A rule spanning several instruments is still checked on the ones you can open; a rule whose condition reads a barred instrument is skipped outright, because the condition decides every verdict. Rights that cannot be read clear nothing.
- The download needs export rights. A user REDCap bars from its data export tool can read the scan on screen, at whatever their value ceiling allows, and cannot download the file.
- Same engine, same verdicts. The scan evaluates through the exact
dispatch the save-hook audit uses (
ruleFindings), so the two can never disagree about what a violation is. Unique rules are checked in one aggregate pass over the scanned data (project/DAG/event scopes honored). - A file that cannot certify says so three ways. The download is a real CSV
from its own page, so REDCap's page chrome can never precede it. An incomplete
scan is marked by a banner at the top, by a terminal data row that survives
deleting the comment lines or sorting the sheet, and by
_INCOMPLETEin the filename, which survives the file being renamed and forwarded. A refused export is not offered as a download at all. CSV cells are quoted and formula-defused, and record ids follow the same privacy mode the audit log uses.
ISO/IEC 7064 Mod 37,36 (default), Mod 11,10, Mod 97,10, Mod 11,2, Mod 37,2, two letters-only variants, plus Damm, Verhoeff, Luhn, four digit-only weighted-modulus schemes (GS1 Mod-10, ABA Mod-10, ICAO MRZ Mod-10, and ISBN-10 weighted Mod-11), and "none" (format/regex only). The method must match how the IDs were minted.
The four weighted-modulus schemes run over a digit payload and each add one
check character (weighted_mod11 may emit X). The three Mod-10 schemes catch
every single-digit error at any length but miss adjacent swaps of digits
differing by 5. weighted_mod11 catches every single-digit error and every
adjacent swap only up to 9 digits (the ISBN-10 domain): at 10+ digits the
position carrying weight 11 goes blind to substitutions, so prefer Mod 11,2 or
Mod 97,10 for longer numbers.
A REDCap admin installs the module once; each project enables it and adds rules
on the settings screen. On every form and survey the module reads its settings,
builds a config object, and injects js/engine.js (the verified engine). Nothing
is hard-coded per project and no third-party module is required.
REDCap settings -> UniversalValidator.php -> window.INSPIRE_VALIDATOR_CONFIG
-> js/engine.js (verified engine)
redcap_save_record -> php/CheckCharacter.php (server guard)
Both runtimes are checked against one fixture generated by the Python source of
truth (qrcode_generation/check_characters.py) — and not only the raw
check-character primitive, but the full runtime path the module actually uses:
tests/parity_js.cjs/tests/parity_php.php— recompute every fixture row across all three sections:compute(the primitive, 574 rows across 15 algorithms),normalize(Unicode dash folding / case / strip), andscheme_ops(append + validate = normalize → source → compute → compare, including every weighted scheme throughdigits_onlyand aweighted_mod11Xcheck tail). 918 rows total.tests/pooled_js.cjs/tests/pooled_php.php— recompute the pooled-field parser for every case intests/pooled_fixture.json(frozen from the verified browser parser), so the server pooled auditor can never drift from the client.tests/risky_js.cjs/tests/risky_php.php— lock the catastrophic-regex (ReDoS) gate to one shared pattern list in both runtimes (nested quantifiers AND repeated alternation/optional groups such as(a|aa)+), and prove the server never turns a PCRE engine failure into a false invalid-ID log.tests/when_js.cjs/tests/when_php.php— lock thewhencondition dialect (parse errors, evaluation verdicts, referenced-field extraction, caps) totests/when_fixture.jsonin both runtimes, so the browser gate and the server audit can never disagree about a condition.tests/when_dom_js.cjsdrives the browser gate itself: live dropdown/radio/checkbox flips, the server-folded constants, fail-open on an unparseable condition, and the guarantee that a gated-off rule never blocks a save.tests/hook_php.phpadditionally asserts that no record value ever reaches the page (SEC-005), on data-entry forms and survey pages alike.tests/when_fuzz_php.php— the cases nobody thought of:gen_when_fuzz.cjsbuilds 4048 seeded conditions (valid ones from the grammar, plus mutated and hostile ones), freezes what the browser twin does with each, and the PHP engine must agree on every accept/reject, verdict and referenced-field list. This is what catches a numeric-vs-string comparison quietly drifting between the runtimes on inputs like1e3,0x10or" 2 ".tests/branching_php.phpandtests/branch_dom_js.cjs— implement the SAME branched-validation scenario table on both sides (active branch, else branch, conflicts, per-branch blockSave/suggestFix, illegal-sharing wording), with the resolver semantics specified inphp/Branching.php;tests/hook_php.phpadditionally drives branch selection through the whole server audit.tests/pooled_dom_js.cjslocks pooled chip severity (invalid and junk red, duplicates amber) with their non-color marks.tests/annotation_php.php— the@UVALIDATEparser and the shared rule validator (checkFragment) used by every configuration channel.tests/hook_php.php— the wholeredcap_save_recordaudit path against a framework mock that refuses settings reads without an explicit project id: privacy modes on success AND exception paths, event/instrument scoping, repeat instances, duplicate-field skips, per-rule isolation, keyed hashing, and the save-timevalidateSettingsgate.tests/a11y_dom_js.cjs— the field-facing DOM contract: live-region status messages,aria-describedby/aria-invalid, label-based block dialogs, debounce, the read-only exemption, and survey muting of technical detail.
CI (.github/workflows/parity.yml) runs the JS suite on Node 20, the PHP suite
on PHP 7.4, 8.1 and 8.3 (the declared floor is exercised, not just stated),
php -l-lints the PHP, checks the pooled fixture is regenerated, and builds a
release-shaped package to verify its layout. If either engine drifts, CI fails.
See tests/README.md.
See docs/INSTALL.md, or the
install page
on the documentation site.
node tests/parity_js.cjs # JS engine vs fixture (compute + normalize + scheme_ops)
php tests/parity_php.php # PHP port vs fixture (PHP 7.4+, needs mbstring + ctype)
node tests/pooled_js.cjs # JS pooled parser vs pooled_fixture.json
php tests/pooled_php.php # PHP pooled parser vs pooled_fixture.json
node tests/risky_js.cjs # JS ReDoS gate vs risky_patterns.json
php tests/risky_php.php # PHP ReDoS gate + server-behavior checks
node tests/when_js.cjs # JS "when" evaluator vs when_fixture.json
php tests/when_php.php # PHP "when" evaluator vs the same fixture
node tests/gen_when_fuzz.cjs # regenerate the seeded when-fuzz fixture
php tests/when_fuzz_php.php # PHP "when" engine vs the JS twin (4048 fuzz cases)
node tests/when_dom_js.cjs # "when" gate DOM contract (live refs, folded consts, fail-open)
php tests/branching_php.php # branch resolver (shared fields -> branch rules)
node tests/branch_dom_js.cjs # branched validation DOM contract (active/else/conflict)
node tests/constraint_dom_js.cjs # @UVASSERT constraint DOM contract (assert test, compose, branches)
node tests/required_dom_js.cjs # @UVREQUIRED required DOM contract (blank, when-gate, compose)
node tests/unique_dom_js.cjs # @UVUNIQUE unique DOM contract (transport, fail-open, cache)
node tests/pooled_dom_js.cjs # pooled chip severity colors + marks
php tests/annotation_php.php # @UVALIDATE parser + shared rule validator
php tests/hook_php.php # redcap_save_record audit path (mocked framework)
node tests/a11y_dom_js.cjs # field DOM contract (a11y, debounce, survey, readonly)
node tests/config_notice_js.cjs # page-level config-error notice
node tests/dispatch_notice_js.cjs # dispatcher config-error routing
node tests/gen_pooled_fixture.cjs # regenerate pooled_fixture.json after a parser changejs/engine.js is vendored from the qrcode_generation repo with a set of
documented deviations (config source, UI-layer security hardening, and the
INSPIREUniversalValidator namespace); see js/README.md for the
authoritative list, how to re-vendor without losing them, and how the cross-repo
fixture contract keeps the two repos in sync. There is also a manual REDCap test
checklist in docs/TESTING.md.
This is the free, open-source client of an open-core system: it catches typos in the form for anyone, and the paid server features (central ID minting, printable QR label sheets, pooled multi-site audit) live behind a hosted API the module can call. The module works fully on its own without any of that.
MIT — see LICENSE.