Skip to content

v1.6.0 — cross-form `@UVASSERT`

Choose a tag to compare

@NghaReformer NghaReformer released this 26 Aug 05:52
· 53 commits to main since this release

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 every scan. The guard
covers the when gate as well as the assert — a gate evaluated against a value that was never
read turns a rule silently off, which is no better than turning it silently on.

unreadable is stronger still: an empty value map is indistinguishable from "every field is
blank" for every rule kind, not just constraints — @UVREQUIRED would report a populated
field as blank and a check rule would pass an invalid ID — so a failed read aborts the whole
audit for that save with a logged error rather than auditing data the module does not have.

A genuinely saved-blank reference still resolves ok and still bakes as ['lit',''] — that
distinction is the whole point, and it is pinned by tests.

Cross-repeating-instrument references are refused, not guessed. Instance 3 of one repeating
form has no defined counterpart in another; REDCap itself requires explicit
[instrument][instance] smart variables to cross that boundary. Guessing by instance number
would be silently wrong whenever the two forms are not created in lockstep, and indistinguishable
from a real violation. Still fully supported: repeating → non-repeating, the base event row, two
fields on the same repeating instrument, and repeating events.

missing is derived from the project's instrument-event mapping — a positive fact — never from
mere absence of data, which is just a blank. Where the mapping cannot be established the module
does not claim missing, which fails open to previous behaviour rather than deferring rules
wrongly.

Editing the referenced form is no longer unguarded (H-02)

A cross-form constraint lives on the instrument carrying the tag, so breaking the relationship by
editing only the referenced side was completely silent: that form installs no client
validator, and the audit's instrument scope excluded the dependent rule. redcap_save_record now
also audits reverse dependencies — rules whose own field is not on the saved instrument but
whose assert/when references a field that is. Scope is widened per-rule only, so PER-001
still holds
: an unrelated instrument with no dependants reads no data and audits nothing, which
its test asserts explicitly.

Numeric comparison is exact decimal, never float (H-03)

9007199254740992 = 9007199254740993 returned true in both runtimes, so the documented
@UVASSERT="[id]=[id_confirm]" recipe accepted two different identifiers. Every numeric-looking
operand was cast to IEEE-754, losing precision above 2^53 and on long fractions. Logic::decCmp
and its twin QRID_whenDecCmp now compare decimal strings exactly — no float, no bcmath, no
BigInt. Documented equivalences are unchanged (02 = 2, 2.50 = 2.5, .5 = 0.5,
-0 = 0), and values NUM_RE rejects (1e3, 0x1F) still take the string path as before.

Blank means the same thing on both sides (M-04)

The browser went inert on any JS-trimmable host value while the server only short-circuited on
exact null/'', so a whitespace-only entry showed no verdict yet logged a violation. Both now
use the charlist the two evaluators already trim with before comparing (" \t\r\n").

A stale snapshot is now explicable (M-02)

Off-page operands are resolved once, when the page is built. If someone edits that form in
another tab the verdict here goes stale, and a wrong hard block was a dead end with no
explanation. A failure now names the field it was compared against and says the value was read
when the page was opened, so the user can reload. Survey respondents still get generic wording.

A deferred rule now says why

The reason a rule stopped checking was built on the server and thrown away: nothing in the engine
ever read deferredWhy, because a deferred rule short-circuits before any message is composed. A
rule that goes quiet with no explanation is the same silent failure the reason exists to prevent,
so staff data-entry forms now show it as a neutral notice — never a pass/fail verdict, never a
block. Survey respondents still see nothing: the reasons name other instruments and fields, and a
respondent cannot act on a design problem anyway.

Reasons are attributed per rule. The first cut collected them page-wide and attached the whole
list to whichever rule deferred first, so that rule was blamed for fields its condition never
mentioned while the rule whose problem it actually was got nothing.

Deferred rules are DETECTION, not enforcement (H-05)

redcap_save_record fires after the write — its own docblock has always said so — and cannot
prevent persistence by any channel. The previous release notes and documentation called the
deferred path "enforcement" and claimed "deferring costs live feedback, never enforcement". That
was materially false: for a survey respondent, or a user without rights to the referenced
instrument, a blockSave:"hard" rule is demoted to off, the invalid value is written, and
the audit logs it afterwards. Every such claim across README, the user guide and the action-tag
examples has been rewritten to say plainly: the save is accepted, the audit records it, the scan
can find it later.


The original 1.6.0 change, for context

A constraint whose assert referenced a field on another instrument was folded whole to a
boolean at page load, freezing the verdict against the saved value of the field the user was
about to type into. Logic::fold() did this deliberately — shipping the other form's value would
reopen SEC-005 — but the consequence was never measured, and it is wrong in both directions:

  • The asserted field blank at load folded "" >= "…" to false, so typing a correct value
    produced an error that hard-blocked the save and that no amount of retyping could clear.
    Cross-form data entry was impossible.
  • A value that was valid at load froze to true, so it could then be replaced with anything at
    all and the browser still showed "OK" and let the save through.

Both reproduced on live REDCap 17.0.6 (pid 149, umt_offrefuht_code): the deployed module
shipped assertAst: ["const", true] and a deliberately wrong value showed "✓ OK.".

The fix. A comparison mixing a live and an off-page reference is now kept live by replacing
the off-page ref with a ['lit', <value>] operand — a node the parser already produces for the
designer's own literals, so both evaluators and the ref-collector handle it unchanged and the
browser watches only the field it can actually see.

That value is baked in only when the viewer is already entitled to read it
(disclosableFields()): data entry only — never a survey page — an authenticated username, and
REDCap form-level rights to the referenced instrument. Every gate fails closed; a field the
viewer may not read discloses nothing, exactly as before. This is the same information REDCap's own
branching logic already ships to the page.

When the value is not disclosable the old fold stands and the rule is marked deferred: the
client states no verdict and never blocks (a stale verdict is wrong both ways, and a frozen
false blocking a correct entry is the bug above), while redcap_save_record still enforces the
constraint and logs any violation as type: constraint. Deferring costs live feedback, never
enforcement.

  • php/Logic.phpfold() takes $disclosable and reports $frozen.
  • UniversalValidator.phpdisclosableFields() / userFormRights() / currentUsername();
    foldRuleConditions() threads the page context and marks deferred rules (branches included).
  • js/engine.jsdeferred joins DEFAULT_KEYS; the constraint factory forces blockSave off
    and states no verdict for a deferred rule.

Rights are read through the framework-native User::getRights($pid) first.
\REDCap::getUserRights()'s FIRST parameter is the user list, not the project id, so
getUserRights($pid) would silently return rights for a user named after the pid — the feature
would go quietly inert on a real REDCap while every mock passed, exactly how @UVUNIQUE shipped
dead in 1.4.0. The static is only a fallback, and is called with no arguments so the parameter
order cannot be got wrong, then filtered by username here.

Tests: tests/crossform_php.php (new, 41 checks) covers the entitled path, both un-entitled paths,
three fail-closed paths, read-only rights, script-breakout of a hostile baked literal, that a
deferred rule is still audited, both rights sources reaching the same verdict, and that a
pid-keyed rights table grants nothing. tests/crossform_adversarial_php.php (new, 44 checks)
red-teams the edges: checkbox refs, $frozen escaping and/or/not nesting, a condition mixing a
disclosable and a non-disclosable ref, per-branch deferral, a frozen when with a live assert,
condition-text de-duplication, empty values, repeating instances, unknown refs, and the other four
rule modes. tests/constraint_dom_js.cjs gains 22 checks (live cross-form contract, the deferred
contract, per-branch deferral, and mode composition). tests/when_fixture.json gains six
cross-runtime cases pinning the ['lit', …]-substituted operand.

Every gate was mutation-tested — survey gate, rights level, fail-closed catch, $frozen,
the disclosable check, the literal substitution, deferred in DEFAULT_KEYS, and the client
deferred short-circuit — and each mutant is caught by at least one check.

Deploy both UniversalValidator.php and js/engine.js. The deployed engine ignores deferred
(verified live), so a PHP-only deploy fixes the entitled path but leaves the un-entitled path
hard-blocking as before.

Docs updated for the new capability and the two limits: the @UVASSERT and when sections of
README.md, the @UVASSERT summary / when semantics / "Referencing a field on another
instrument" recipe in docs/action_tag_validation_examples.md (which now separates the two
comparison shapes — off-page-vs-literal still settles at page load, off-page-vs-on-form is live),
and a new cross-instrument entry in the docs/USER_GUIDE.md FAQ. Every @UVASSERT condition
printed in the docs was re-parsed with the module's own dialect parser (88 conditions, 0 errors).

Still not supported, and now stated plainly rather than failing silently: a reference to a field
on another event. readValues() scopes getData to the hook's event_id, so an off-event ref
reads '' and the assert passes — in the browser and in the audit. Keep both fields in one event.


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